diff --git a/app/models/project.rb b/app/models/project.rb
index ec77ab7011acbf9d6ffd06dfc0c7e334120351d9..a9552bbb3ffd7322325574fae0f6f7a498129f60 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -1541,6 +1541,16 @@ def github_enterprise_import?
       URI.parse(import_url).host != URI.parse(Octokit::Default::API_ENDPOINT).host
   end
 
+  # Determine whether any kind of import is in progress.
+  # - Full file import
+  # - Relation import
+  # - Direct Transfer
+  def any_import_in_progress?
+    relation_import_trackers.last&.started? ||
+      import_started? ||
+      BulkImports::Entity.with_status(:started).where(project_id: id).any?
+  end
+
   def has_remote_mirror?
     remote_mirror_available? && remote_mirrors.enabled.exists?
   end
diff --git a/app/services/projects/import_export/relation_import_service.rb b/app/services/projects/import_export/relation_import_service.rb
new file mode 100644
index 0000000000000000000000000000000000000000..e99fb81c768b77f2dd247e638e1b990e0c460777
--- /dev/null
+++ b/app/services/projects/import_export/relation_import_service.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+# Imports a selected relation into an existing project, skipping any identified
+# duplicates. Duplicates are matched on the `iid` of the record being imported.
+module Projects
+  module ImportExport
+    class RelationImportService
+      include ::Services::ReturnServiceResponses
+
+      IMPORTABLE_RELATIONS = %w[issues merge_requests milestones ci_pipelines].freeze
+
+      # Creates a new RelationImportService.
+      #
+      # @param [User] current_user
+      # @param [Hash] params
+      # @option params [String] path The full path of the project
+      # @option params [String] relation The relation to import. See IMPORTABLE_RELATIONS for permitted values.
+      # @option params [UploadedFile] file The export archive containing the data to import
+      def initialize(current_user:, params:)
+        @current_user = current_user
+        @params = params
+      end
+
+      # Checks the validity of the chosen project and triggers the re-import of
+      # the chosen relation.
+      #
+      # @return [Services::ServiceResponse]
+      def execute
+        return error(_('Project not found'), :not_found) unless project
+
+        unless relation_valid?
+          return error(
+            format(
+              _('Imported relation must be one of %{relations}'),
+              relations: IMPORTABLE_RELATIONS.to_sentence(last_word_connector: ', or ')
+            ),
+            :bad_request
+          )
+        end
+
+        return error(_('You are not authorized to perform this action'), :forbidden) unless user_permitted?
+        return error(_('A relation import is already in progress for this project'), :conflict) if import_in_progress?
+
+        tracker = create_status_tracker
+
+        attach_import_file
+
+        Projects::ImportExport::RelationImportWorker.perform_async(
+          tracker.id,
+          current_user.id
+        )
+
+        success(tracker)
+      end
+
+      private
+
+      attr_reader :current_user, :params
+
+      def user_permitted?
+        Ability.allowed?(current_user, :admin_project, project)
+      end
+
+      def relation_valid?
+        IMPORTABLE_RELATIONS.include?(params[:relation])
+      end
+
+      def attach_import_file
+        project.update(import_export_upload: ImportExportUpload.new(import_file: params[:file]))
+      end
+
+      def create_status_tracker
+        project.relation_import_trackers.create(
+          relation: params[:relation]
+        )
+      end
+
+      def project
+        @project ||= Project.find_by_full_path(params[:path])
+      end
+
+      def import_in_progress?
+        project.any_import_in_progress?
+      end
+    end
+  end
+end
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index 5ec4a96802513ec1363291761345012ed5e869fb..45d74c93656d21d338aa62de54b297e4c2860be1 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -3765,6 +3765,15 @@
   :weight: 1
   :idempotent: true
   :tags: []
+- :name: projects_import_export_relation_import
+  :worker_name: Projects::ImportExport::RelationImportWorker
+  :feature_category: :importers
+  :has_external_dependencies: false
+  :urgency: :low
+  :resource_boundary: :memory
+  :weight: 1
+  :idempotent: true
+  :tags: []
 - :name: projects_import_export_wait_relation_exports
   :worker_name: Projects::ImportExport::WaitRelationExportsWorker
   :feature_category: :importers
diff --git a/app/workers/projects/import_export/relation_import_worker.rb b/app/workers/projects/import_export/relation_import_worker.rb
new file mode 100644
index 0000000000000000000000000000000000000000..c30b20593c49dd57e65006a565cff1a7977dab97
--- /dev/null
+++ b/app/workers/projects/import_export/relation_import_worker.rb
@@ -0,0 +1,96 @@
+# frozen_string_literal: true
+
+module Projects
+  module ImportExport
+    class RelationImportWorker
+      include ApplicationWorker
+
+      sidekiq_options retry: 6
+
+      idempotent!
+      data_consistency :delayed
+      deduplicate :until_executed
+      feature_category :importers
+      urgency :low
+      worker_resource_boundary :memory
+
+      attr_reader :tracker, :project, :current_user
+
+      def perform(tracker_id, user_id)
+        @current_user = User.find(user_id)
+        @tracker = ::Projects::ImportExport::RelationImportTracker.find(tracker_id)
+        @project = tracker.project
+
+        return unless tracker.can_start?
+
+        tracker.start!
+
+        extract_import_file
+        process_import
+
+        tracker.finish!
+      rescue StandardError => error
+        failure_service = Gitlab::ImportExport::ImportFailureService.new(project)
+        failure_service.log_import_failure(
+          source: 'RelationImportWorker#perform',
+          exception: error,
+          relation_key: tracker.relation
+        )
+
+        tracker.fail_op!
+
+        raise
+      end
+
+      private
+
+      def extract_import_file
+        Gitlab::ImportExport::FileImporter.import(
+          importable: project,
+          archive_file: project.import_export_upload.import_file.path,
+          shared: shared_export_data
+        )
+      end
+
+      def process_import
+        tree_restorer = Gitlab::ImportExport::Project::RelationTreeRestorer.new(
+          user: current_user,
+          shared: shared_export_data,
+          relation_reader: relation_reader,
+          object_builder: Gitlab::ImportExport::Project::ObjectBuilder,
+          members_mapper: members_mapper,
+          relation_factory: Gitlab::ImportExport::Project::RelationFactory,
+          reader: Gitlab::ImportExport::Reader.new(shared: shared_export_data),
+          importable: project,
+          importable_attributes: relation_reader.consume_attributes('project'),
+          importable_path: 'project',
+          skip_on_duplicate_iid: true
+        )
+
+        tree_restorer.restore_single_relation(tracker.relation)
+      end
+
+      def relation_reader
+        @relation_reader ||= Gitlab::ImportExport::Json::NdjsonReader.new(
+          File.join(shared_export_data.export_path, 'tree')
+        )
+      end
+
+      def members_mapper
+        project_members = relation_reader
+                            .consume_relation('project', 'project_members', mark_as_consumed: false)
+                            .map(&:first)
+
+        Gitlab::ImportExport::MembersMapper.new(
+          exported_members: project_members,
+          user: current_user,
+          importable: project
+        )
+      end
+
+      def shared_export_data
+        @shared ||= project.import_export_shared
+      end
+    end
+  end
+end
diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml
index 2233e6ba322814b5306b566299b758271395b716..ab4b7d200ac3f0f81f494d7f471acc5ddc90172d 100644
--- a/config/sidekiq_queues.yml
+++ b/config/sidekiq_queues.yml
@@ -611,6 +611,8 @@
   - 1
 - - projects_import_export_relation_export
   - 1
+- - projects_import_export_relation_import
+  - 1
 - - projects_import_export_wait_relation_exports
   - 1
 - - projects_inactive_projects_deletion_notification
diff --git a/lib/gitlab/import_export/group/relation_tree_restorer.rb b/lib/gitlab/import_export/group/relation_tree_restorer.rb
index ae47c95036ea5003df00d9ea01eb72661af05074..f800bae2548d58ad0e187cf2f1524ff9e10d75af 100644
--- a/lib/gitlab/import_export/group/relation_tree_restorer.rb
+++ b/lib/gitlab/import_export/group/relation_tree_restorer.rb
@@ -4,6 +4,8 @@ module Gitlab
   module ImportExport
     module Group
       class RelationTreeRestorer
+        include Gitlab::Utils::StrongMemoize
+
         def initialize( # rubocop:disable Metrics/ParameterLists
           user:,
           shared:,
@@ -14,7 +16,8 @@ def initialize( # rubocop:disable Metrics/ParameterLists
           reader:,
           importable:,
           importable_attributes:,
-          importable_path:
+          importable_path:,
+          skip_on_duplicate_iid: false
         )
           @user = user
           @shared = shared
@@ -26,15 +29,28 @@ def initialize( # rubocop:disable Metrics/ParameterLists
           @reader = reader
           @importable_attributes = importable_attributes
           @importable_path = importable_path
+          @skip_on_duplicate_iid = skip_on_duplicate_iid
         end
 
         def restore
+          bulk_insert_without_cache_or_touch do
+            create_relations!
+          end
+        end
+
+        def restore_single_relation(relation_key)
+          # NO-OP. This is currently only available for file-based project import.
+        end
+
+        private
+
+        def bulk_insert_without_cache_or_touch
           Gitlab::Database.all_uncached do
             ActiveRecord::Base.no_touching do
               update_params!
 
               BulkInsertableAssociations.with_bulk_insert(enabled: bulk_insert_enabled) do
-                create_relations!
+                yield
               end
             end
           end
@@ -48,12 +64,14 @@ def restore
           false
         end
 
-        private
-
         def bulk_insert_enabled
           false
         end
 
+        def skip_on_duplicate_iid?
+          @skip_on_duplicate_iid
+        end
+
         # Loops through the tree of models defined in import_export.yml and
         # finds them in the imported JSON so they can be instantiated and saved
         # in the DB. The structure and relationships between models are guessed from
@@ -73,8 +91,10 @@ def process_relation!(relation_key, relation_definition)
 
         def process_relation_item!(relation_key, relation_definition, relation_index, data_hash)
           relation_object = build_relation(relation_key, relation_definition, relation_index, data_hash)
+
           return unless relation_object
           return if relation_invalid_for_importable?(relation_object)
+          return if skip_on_duplicate_iid? && previously_imported?(relation_object, relation_key)
 
           relation_object.assign_attributes(importable_class_sym => @importable)
 
@@ -88,6 +108,25 @@ def process_relation_item!(relation_key, relation_definition, relation_index, da
             external_identifiers: external_identifiers(data_hash))
         end
 
+        def previously_imported?(relation_object, relation_key)
+          existing_iids(relation_key).key?(relation_object.iid)
+        end
+
+        # Generate the list of existing IIDs as a hash.
+        # { issues: { 1: true, 2: true, ... }}
+        # A hash is used rather than returning the array of IDs because lookup
+        # performance is greatly improved.
+        def existing_iids(relation_key)
+          strong_memoize_with(:existing_iids, relation_key) do
+            case relation_key
+            when 'issues' then @importable.issues.pluck(:iid)
+            when 'milestones' then @importable.milestones.pluck(:iid)
+            when 'ci_pipelines' then @importable.ci_pipelines.pluck(:iid)
+            when 'merge_requests' then @importable.merge_requests.pluck(:iid)
+            end.index_with(true)
+          end
+        end
+
         def save_relation_object(relation_object, relation_key, relation_definition, relation_index)
           if relation_object.new_record?
             saver = Gitlab::ImportExport::Base::RelationObjectSaver.new(
diff --git a/lib/gitlab/import_export/project/relation_tree_restorer.rb b/lib/gitlab/import_export/project/relation_tree_restorer.rb
index b5247754199e88a14ffb8b68a6780009c49e35b4..056c30a996f9d6997f342551fff08e3dd728774d 100644
--- a/lib/gitlab/import_export/project/relation_tree_restorer.rb
+++ b/lib/gitlab/import_export/project/relation_tree_restorer.rb
@@ -7,6 +7,12 @@ class RelationTreeRestorer < ImportExport::Group::RelationTreeRestorer
         # Relations which cannot be saved at project level (and have a group assigned)
         GROUP_MODELS = [GroupLabel, Milestone, Epic].freeze
 
+        def restore_single_relation(relation_key)
+          bulk_insert_without_cache_or_touch do
+            process_relation!(relation_key, relations[relation_key])
+          end
+        end
+
         private
 
         def group_models
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index da277161f23464234a87b1b81e9e020ef102c941..6f63b2052273ac6830c321e86374386d51c92426 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -1957,6 +1957,9 @@ msgstr ""
 msgid "A ready-to-go template for use with iOS Swift apps"
 msgstr ""
 
+msgid "A relation import is already in progress for this project"
+msgstr ""
+
 msgid "A release with a date in the future is labeled as an %{linkStart}Upcoming Release%{linkEnd}."
 msgstr ""
 
@@ -26355,6 +26358,9 @@ msgstr ""
 msgid "ImportProjects|Update of imported projects with realtime changes failed"
 msgstr ""
 
+msgid "Imported relation must be one of %{relations}"
+msgstr ""
+
 msgid "Imported requirements"
 msgstr ""
 
@@ -39525,6 +39531,9 @@ msgstr ""
 msgid "Project navigation"
 msgstr ""
 
+msgid "Project not found"
+msgstr ""
+
 msgid "Project or Group"
 msgstr ""
 
diff --git a/spec/commands/sidekiq_cluster/cli_spec.rb b/spec/commands/sidekiq_cluster/cli_spec.rb
index f6511007a5111e247110a7ee0cbe2c47d5a4180a..1d8196a6a19a40271f494c4bee8230fd4e309eac 100644
--- a/spec/commands/sidekiq_cluster/cli_spec.rb
+++ b/spec/commands/sidekiq_cluster/cli_spec.rb
@@ -262,12 +262,12 @@
               if Gitlab.ee?
                 [
                   %w[incident_management_close_incident status_page_publish],
-                  %w[bulk_imports_pipeline bulk_imports_pipeline_batch bulk_imports_relation_batch_export bulk_imports_relation_export project_export projects_import_export_parallel_project_export projects_import_export_relation_export repository_import project_template_export]
+                  %w[bulk_imports_pipeline bulk_imports_pipeline_batch bulk_imports_relation_batch_export bulk_imports_relation_export project_export projects_import_export_parallel_project_export projects_import_export_relation_export projects_import_export_relation_import repository_import project_template_export]
                 ]
               else
                 [
                   %w[incident_management_close_incident],
-                  %w[bulk_imports_pipeline bulk_imports_pipeline_batch bulk_imports_relation_batch_export bulk_imports_relation_export project_export projects_import_export_parallel_project_export projects_import_export_relation_export repository_import]
+                  %w[bulk_imports_pipeline bulk_imports_pipeline_batch bulk_imports_relation_batch_export bulk_imports_relation_export project_export projects_import_export_parallel_project_export projects_import_export_relation_export projects_import_export_relation_import repository_import]
                 ]
               end
 
diff --git a/spec/factories/projects/import_export/relation_import_tracker.rb b/spec/factories/projects/import_export/relation_import_tracker.rb
index 255b7d7c04ba34f64de73010abf27d3fd0809db9..5dc69242e71b32f05e5683564d0653c4dbb7274d 100644
--- a/spec/factories/projects/import_export/relation_import_tracker.rb
+++ b/spec/factories/projects/import_export/relation_import_tracker.rb
@@ -6,5 +6,9 @@
 
     relation { :issues }
     status { 0 }
+
+    trait :started do
+      status { 1 }
+    end
   end
 end
diff --git a/spec/lib/gitlab/import_export/group/relation_tree_restorer_spec.rb b/spec/lib/gitlab/import_export/group/relation_tree_restorer_spec.rb
index 8ab99875a0a8b7bd8d1ad1dd1ff049b693124651..c31451ac83343105bc06f587830cf0eae2488508 100644
--- a/spec/lib/gitlab/import_export/group/relation_tree_restorer_spec.rb
+++ b/spec/lib/gitlab/import_export/group/relation_tree_restorer_spec.rb
@@ -10,164 +10,166 @@
 require 'spec_helper'
 
 RSpec.describe Gitlab::ImportExport::Group::RelationTreeRestorer, feature_category: :importers do
-  let(:group) { create(:group).tap { |g| g.add_owner(user) } }
-  let(:importable) { create(:group, parent: group) }
+  describe '#restore' do
+    let(:group) { create(:group).tap { |g| g.add_owner(user) } }
+    let(:importable) { create(:group, parent: group) }
 
-  include_context 'relation tree restorer shared context' do
-    let(:importable_name) { 'groups/4353' }
-  end
-
-  let(:path) { Rails.root.join('spec/fixtures/lib/gitlab/import_export/group_exports/no_children/tree') }
-  let(:relation_reader) do
-    Gitlab::ImportExport::Json::NdjsonReader.new(path)
-  end
+    include_context 'relation tree restorer shared context' do
+      let(:importable_name) { 'groups/4353' }
+    end
 
-  let(:reader) do
-    Gitlab::ImportExport::Reader.new(
-      shared: shared,
-      config: Gitlab::ImportExport::Config.new(config: Gitlab::ImportExport.group_config_file).to_h
-    )
-  end
+    let(:path) { Rails.root.join('spec/fixtures/lib/gitlab/import_export/group_exports/no_children/tree') }
+    let(:relation_reader) do
+      Gitlab::ImportExport::Json::NdjsonReader.new(path)
+    end
 
-  let(:members_mapper) do
-    Gitlab::ImportExport::MembersMapper.new(
-      exported_members: relation_reader.consume_relation(importable_name, 'members').map(&:first),
-      user: user,
-      importable: importable
-    )
-  end
+    let(:reader) do
+      Gitlab::ImportExport::Reader.new(
+        shared: shared,
+        config: Gitlab::ImportExport::Config.new(config: Gitlab::ImportExport.group_config_file).to_h
+      )
+    end
 
-  let(:relation_tree_restorer) do
-    described_class.new(
-      user: user,
-      shared: shared,
-      relation_reader: relation_reader,
-      object_builder: Gitlab::ImportExport::Group::ObjectBuilder,
-      members_mapper: members_mapper,
-      relation_factory: Gitlab::ImportExport::Group::RelationFactory,
-      reader: reader,
-      importable: importable,
-      importable_path: importable_name,
-      importable_attributes: attributes
-    )
-  end
+    let(:members_mapper) do
+      Gitlab::ImportExport::MembersMapper.new(
+        exported_members: relation_reader.consume_relation(importable_name, 'members').map(&:first),
+        user: user,
+        importable: importable
+      )
+    end
 
-  subject(:restore_relations) { relation_tree_restorer.restore }
+    let(:relation_tree_restorer) do
+      described_class.new(
+        user: user,
+        shared: shared,
+        relation_reader: relation_reader,
+        object_builder: Gitlab::ImportExport::Group::ObjectBuilder,
+        members_mapper: members_mapper,
+        relation_factory: Gitlab::ImportExport::Group::RelationFactory,
+        reader: reader,
+        importable: importable,
+        importable_path: importable_name,
+        importable_attributes: attributes
+      )
+    end
 
-  it 'restores group tree' do
-    expect(restore_relations).to eq(true)
-  end
+    subject(:restore_relations) { relation_tree_restorer.restore }
 
-  it 'logs top-level relation creation' do
-    expect(shared.logger)
-      .to receive(:info)
-      .with(hash_including(message: '[Project/Group Import] Created new object relation'))
-      .at_least(:once)
+    it 'restores group tree' do
+      expect(restore_relations).to eq(true)
+    end
 
-    restore_relations
-  end
+    it 'logs top-level relation creation' do
+      expect(shared.logger)
+        .to receive(:info)
+        .with(hash_including(message: '[Project/Group Import] Created new object relation'))
+        .at_least(:once)
 
-  describe 'relation object saving' do
-    before do
-      allow(shared.logger).to receive(:info).and_call_original
-      allow(relation_reader).to receive(:consume_relation).and_call_original
+      restore_relations
     end
 
-    context 'when relation object is new' do
+    describe 'relation object saving' do
       before do
-        allow(relation_reader)
-          .to receive(:consume_relation)
-          .with(importable_name, 'boards')
-          .and_return([[board, 0]])
+        allow(shared.logger).to receive(:info).and_call_original
+        allow(relation_reader).to receive(:consume_relation).and_call_original
       end
 
-      context 'when relation object has invalid subrelations' do
-        let(:board) do
-          {
-            'name' => 'test',
-            'lists' => [List.new, List.new],
-            'group_id' => importable.id
-          }
+      context 'when relation object is new' do
+        before do
+          allow(relation_reader)
+            .to receive(:consume_relation)
+            .with(importable_name, 'boards')
+            .and_return([[board, 0]])
         end
 
-        it 'logs invalid subrelations' do
-          expect(shared.logger)
-            .to receive(:info)
-            .with(
-              message: '[Project/Group Import] Invalid subrelation',
-              group_id: importable.id,
-              relation_key: 'boards',
-              error_messages: "Label can't be blank, Position can't be blank, and Position is not a number"
-            )
-
-          restore_relations
-
-          board = importable.boards.last
-          failure = importable.import_failures.first
-
-          expect(importable.import_failures.count).to eq(2)
-          expect(board.name).to eq('test')
-          expect(failure.exception_class).to eq('ActiveRecord::RecordInvalid')
-          expect(failure.source).to eq('RelationTreeRestorer#save_relation_object')
-          expect(failure.exception_message)
-            .to eq("Label can't be blank, Position can't be blank, and Position is not a number")
+        context 'when relation object has invalid subrelations' do
+          let(:board) do
+            {
+              'name' => 'test',
+              'lists' => [List.new, List.new],
+              'group_id' => importable.id
+            }
+          end
+
+          it 'logs invalid subrelations' do
+            expect(shared.logger)
+              .to receive(:info)
+              .with(
+                message: '[Project/Group Import] Invalid subrelation',
+                group_id: importable.id,
+                relation_key: 'boards',
+                error_messages: "Label can't be blank, Position can't be blank, and Position is not a number"
+              )
+
+            restore_relations
+
+            board = importable.boards.last
+            failure = importable.import_failures.first
+
+            expect(importable.import_failures.count).to eq(2)
+            expect(board.name).to eq('test')
+            expect(failure.exception_class).to eq('ActiveRecord::RecordInvalid')
+            expect(failure.source).to eq('RelationTreeRestorer#save_relation_object')
+            expect(failure.exception_message)
+              .to eq("Label can't be blank, Position can't be blank, and Position is not a number")
+          end
         end
       end
-    end
 
-    context 'when invalid relation object has a loggable external identifier' do
-      before do
-        allow(relation_reader)
-          .to receive(:consume_relation)
-          .with(importable_name, 'milestones')
-          .and_return([
-            [invalid_milestone, 0],
-            [invalid_milestone_with_no_iid, 1]
-          ])
-      end
+      context 'when invalid relation object has a loggable external identifier' do
+        before do
+          allow(relation_reader)
+            .to receive(:consume_relation)
+            .with(importable_name, 'milestones')
+            .and_return([
+              [invalid_milestone, 0],
+              [invalid_milestone_with_no_iid, 1]
+            ])
+        end
 
-      let(:invalid_milestone) { build(:milestone, iid: 123, name: nil) }
-      let(:invalid_milestone_with_no_iid) { build(:milestone, iid: nil, name: nil) }
+        let(:invalid_milestone) { build(:milestone, iid: 123, name: nil) }
+        let(:invalid_milestone_with_no_iid) { build(:milestone, iid: nil, name: nil) }
 
-      it 'logs invalid record with external identifier' do
-        restore_relations
+        it 'logs invalid record with external identifier' do
+          restore_relations
 
-        iids_for_failures = importable.import_failures.collect { |f| [f.relation_key, f.external_identifiers] }
-        expected_iids = [
-          ["milestones", { "iid" => invalid_milestone.iid }],
-          ["milestones", {}]
-        ]
+          iids_for_failures = importable.import_failures.collect { |f| [f.relation_key, f.external_identifiers] }
+          expected_iids = [
+            ["milestones", { "iid" => invalid_milestone.iid }],
+            ["milestones", {}]
+          ]
 
-        expect(iids_for_failures).to match_array(expected_iids)
+          expect(iids_for_failures).to match_array(expected_iids)
+        end
       end
-    end
 
-    context 'when relation object is persisted' do
-      before do
-        allow(relation_reader)
-          .to receive(:consume_relation)
-          .with(importable_name, 'labels')
-          .and_return([[label, 0]])
-      end
+      context 'when relation object is persisted' do
+        before do
+          allow(relation_reader)
+            .to receive(:consume_relation)
+            .with(importable_name, 'labels')
+            .and_return([[label, 0]])
+        end
 
-      context 'when relation object is invalid' do
-        let(:label) { create(:group_label, group: group, title: 'test') }
+        context 'when relation object is invalid' do
+          let(:label) { create(:group_label, group: group, title: 'test') }
 
-        it 'saves import failure with nested errors' do
-          label.priorities << [LabelPriority.new, LabelPriority.new]
+          it 'saves import failure with nested errors' do
+            label.priorities << [LabelPriority.new, LabelPriority.new]
 
-          restore_relations
+            restore_relations
 
-          failure = importable.import_failures.first
+            failure = importable.import_failures.first
 
-          expect(importable.labels.count).to eq(0)
-          expect(importable.import_failures.count).to eq(1)
-          expect(failure.exception_class).to eq('ActiveRecord::RecordInvalid')
-          expect(failure.source).to eq('process_relation_item!')
-          expect(failure.exception_message)
-            .to eq("Validation failed: Priorities is invalid, Project can't be blank, Priority can't be blank, " \
-                   "Priority is not a number, Project can't be blank, Priority can't be blank, " \
-                   "Priority is not a number")
+            expect(importable.labels.count).to eq(0)
+            expect(importable.import_failures.count).to eq(1)
+            expect(failure.exception_class).to eq('ActiveRecord::RecordInvalid')
+            expect(failure.source).to eq('process_relation_item!')
+            expect(failure.exception_message)
+              .to eq("Validation failed: Priorities is invalid, Project can't be blank, Priority can't be blank, " \
+                     "Priority is not a number, Project can't be blank, Priority can't be blank, " \
+                     "Priority is not a number")
+          end
         end
       end
     end
diff --git a/spec/lib/gitlab/import_export/project/relation_tree_restorer_spec.rb b/spec/lib/gitlab/import_export/project/relation_tree_restorer_spec.rb
index 0f4f2eb573c03e6426ef5bca27e8cbb660ced593..f610d48ca90d5631150017c2ac4f3588ac209fa7 100644
--- a/spec/lib/gitlab/import_export/project/relation_tree_restorer_spec.rb
+++ b/spec/lib/gitlab/import_export/project/relation_tree_restorer_spec.rb
@@ -88,4 +88,134 @@
       relation_tree_restorer.restore
     end
   end
+
+  describe '#restore_single_relation' do
+    let_it_be(:importable) { create(:project) }
+
+    let(:relation_reader) do
+      Gitlab::ImportExport::Json::NdjsonReader.new(
+        'spec/fixtures/lib/gitlab/import_export/complex/tree'
+      )
+    end
+
+    let(:relation_tree_restorer) do
+      described_class.new(
+        user: user,
+        shared: shared,
+        relation_reader: relation_reader,
+        object_builder: Gitlab::ImportExport::Project::ObjectBuilder,
+        members_mapper: members_mapper,
+        relation_factory: Gitlab::ImportExport::Project::RelationFactory,
+        reader: reader,
+        importable: importable,
+        importable_path: importable_name,
+        importable_attributes: attributes,
+        skip_on_duplicate_iid: skip_on_duplicate_iid
+      )
+    end
+
+    subject(:restore_relations) { relation_tree_restorer.restore_single_relation(relation_key) }
+
+    shared_examples 'saving single relation' do
+      context 'when skipping existing IIDs' do
+        let(:skip_on_duplicate_iid) { true }
+
+        it 'does not attempt to save the duplicate relation' do
+          expect(relation_tree_restorer).not_to receive(:save_relation_object)
+
+          restore_relations
+        end
+      end
+
+      context 'when not skipping existing IIDs' do
+        let(:skip_on_duplicate_iid) { false }
+
+        it 'attempts to save the duplicate relation' do
+          expect(relation_tree_restorer).to receive(:save_relation_object).once
+
+          restore_relations
+        end
+      end
+    end
+
+    context 'when importing issues' do
+      let(:relation_key) { 'issues' }
+
+      before do
+        importable.issues.create!(iid: 123, title: 'Issue', author: user)
+
+        allow(relation_reader)
+          .to receive(:consume_relation)
+          .with(importable_name, 'issues')
+          .and_return([[build(:issue, iid: 123, title: 'Issue', author_id: user.id), 0]])
+      end
+
+      include_examples 'saving single relation'
+    end
+
+    context 'when importing milestones' do
+      let(:relation_key) { 'milestones' }
+
+      before do
+        importable.milestones.create!(iid: 123, title: 'Milestone')
+
+        allow(relation_reader)
+          .to receive(:consume_relation)
+          .with(importable_name, 'milestones')
+          .and_return([[build(:milestone, iid: 123, name: 'Milestone'), 0]])
+      end
+
+      include_examples 'saving single relation'
+    end
+
+    context 'when importing CI pipelines' do
+      let(:relation_key) { 'ci_pipelines' }
+
+      before do
+        create(
+          :ci_pipeline,
+          project: importable,
+          iid: 123
+        )
+
+        allow(relation_reader)
+          .to receive(:consume_relation)
+          .with(importable_name, 'ci_pipelines')
+          .and_return([[build(:ci_pipeline, iid: 123), 0]])
+      end
+
+      include_examples 'saving single relation'
+    end
+
+    context 'when importing merge requests' do
+      let(:relation_key) { 'merge_requests' }
+
+      before do
+        create(
+          :merge_request,
+          iid: 123,
+          source_project: importable,
+          target_project: importable
+        )
+
+        allow(relation_reader)
+          .to receive(:consume_relation)
+          .with(importable_name, 'merge_requests')
+          .and_return([[build(:merge_request, iid: 123), 0]])
+      end
+
+      include_examples 'saving single relation'
+    end
+
+    context 'when importing an unknown relation' do
+      let(:relation_key) { 'unknown' }
+      let(:skip_on_duplicate_iid) { false }
+
+      it 'does not attempt an import' do
+        expect(relation_tree_restorer).not_to receive(:save_relation_object)
+
+        restore_relations
+      end
+    end
+  end
 end
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index 5a3d5c147e5e47bcf94f030fd833da6d94ae2bb5..e1427ce3a902329216a0e27af8889353834f5065 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -4032,6 +4032,40 @@ def has_external_wiki
     it { expect(project.gitea_import?).to be true }
   end
 
+  describe '#any_import_in_progress?' do
+    let_it_be_with_reload(:project) { create(:project) }
+
+    subject { project.any_import_in_progress? }
+
+    context 'when a file import is in progress' do
+      before do
+        create(:import_state, :started, project: project)
+      end
+
+      it { is_expected.to be_truthy }
+    end
+
+    context 'when a relation import is in progress' do
+      before do
+        create(:relation_import_tracker, :started, project: project)
+      end
+
+      it { is_expected.to be_truthy }
+    end
+
+    context 'when direct transfer is in progress' do
+      before do
+        create(:bulk_import_entity, :project_entity, :started, project: project)
+      end
+
+      it { is_expected.to be_truthy }
+    end
+
+    context 'when no imports are in progress' do
+      it { is_expected.to be_falsy }
+    end
+  end
+
   describe '#has_remote_mirror?' do
     let(:project) { create(:project, :remote_mirror, :import_started) }
 
diff --git a/spec/services/projects/import_export/relation_import_service_spec.rb b/spec/services/projects/import_export/relation_import_service_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..6c2dc49d4605ddd280a18d8116e0344d74c4fb59
--- /dev/null
+++ b/spec/services/projects/import_export/relation_import_service_spec.rb
@@ -0,0 +1,94 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.shared_examples 'returns failure response' do |expected_status|
+  it 'returns an error status' do
+    response = import_service.execute
+
+    expect(response).to be_instance_of(ServiceResponse)
+    expect(response).not_to be_success
+    expect(response.http_status).to eq(expected_status)
+  end
+end
+
+RSpec.describe ::Projects::ImportExport::RelationImportService, :aggregate_failures, feature_category: :importers do
+  let_it_be(:project) { create(:project) }
+
+  let(:params) do
+    {
+      path: project_path,
+      file: fixture_file_upload('spec/features/projects/import_export/test_project_export.tar.gz', 'application/gzip'),
+      relation: relation
+    }
+  end
+
+  let(:relation) { 'issues' }
+
+  let_it_be(:user) { create(:user) }
+
+  subject(:import_service) { described_class.new(current_user: user, params: params) }
+
+  describe '#execute' do
+    context 'when the project exists' do
+      let(:project_path) { project.full_path }
+
+      context 'and the user is a maintainer' do
+        before_all do
+          project.add_maintainer(user)
+        end
+
+        it 'schedules a restore of the relation' do
+          expect(Projects::ImportExport::RelationImportWorker).to receive(:perform_async)
+
+          import_service.execute
+        end
+
+        it 'returns a service response' do
+          response = import_service.execute
+
+          expect(response).to be_instance_of(ServiceResponse)
+          expect(response).to be_success
+          expect(response.http_status).to eq(:ok)
+          expect(response.payload).to be_instance_of(Projects::ImportExport::RelationImportTracker)
+        end
+      end
+
+      context 'and the user has developer access' do
+        before_all do
+          project.add_developer(user)
+        end
+
+        include_examples 'returns failure response', :forbidden
+      end
+
+      context 'and the has no access' do
+        include_examples 'returns failure response', :forbidden
+      end
+
+      context 'and the user triggers an import before the last one finishes' do
+        before_all do
+          project.add_maintainer(user)
+        end
+
+        before do
+          project.relation_import_trackers.create!(relation: 'issues', status: 1)
+        end
+
+        include_examples 'returns failure response', :conflict
+      end
+
+      context 'and an invalid relation is passed' do
+        let(:relation) { 'invalid_relation' }
+
+        include_examples 'returns failure response', :bad_request
+      end
+    end
+
+    context 'when the project does not exist' do
+      let(:project_path) { 'some/unknown/project' }
+
+      include_examples 'returns failure response', :not_found
+    end
+  end
+end
diff --git a/spec/workers/every_sidekiq_worker_spec.rb b/spec/workers/every_sidekiq_worker_spec.rb
index 5597b8f16353a6b8f5326a5791b597e586f2705c..f42d56a819ba065a0d1ff54102842d85c605defd 100644
--- a/spec/workers/every_sidekiq_worker_spec.rb
+++ b/spec/workers/every_sidekiq_worker_spec.rb
@@ -410,6 +410,7 @@
         'Projects::DeregisterSuggestedReviewersProjectWorker' => 3,
         'Projects::DisableLegacyOpenSourceLicenseForInactiveProjectsWorker' => 3,
         'Projects::GitGarbageCollectWorker' => false,
+        'Projects::ImportExport::RelationImportWorker' => 6,
         'Projects::InactiveProjectsDeletionNotificationWorker' => 3,
         'Projects::PostCreationWorker' => 3,
         'Projects::ScheduleBulkRepositoryShardMovesWorker' => 3,
diff --git a/spec/workers/projects/import_export/relation_import_worker_spec.rb b/spec/workers/projects/import_export/relation_import_worker_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..28bff08f57ac9818fa53112cd8232f7dabb6b087
--- /dev/null
+++ b/spec/workers/projects/import_export/relation_import_worker_spec.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::ImportExport::RelationImportWorker, feature_category: :importers do
+  let_it_be_with_reload(:tracker) { create(:relation_import_tracker, status: 'created') }
+
+  let_it_be(:user) { create(:user) }
+  let(:worker) { described_class.new }
+
+  subject(:perform) { worker.perform(tracker.id, user.id) }
+
+  before do
+    tracker.project.update!(import_export_upload: create(
+      :import_export_upload,
+      import_file: fixture_file_upload('spec/features/projects/import_export/test_project_export.tar.gz')
+    ))
+  end
+
+  context 'when the import succeeds' do
+    it 'marks the tracker as finished' do
+      expect { perform }.to change { tracker.reload.finished? }.from(false).to(true)
+    end
+  end
+
+  context 'when the import fails' do
+    before do
+      allow(worker).to receive(:process_import).and_raise(StandardError, 'import_forced_to_fail')
+    end
+
+    it 'marks the tracker as failed' do
+      expect { perform }
+        .to raise_error(StandardError, 'import_forced_to_fail')
+        .and change { tracker.reload.failed? }.from(false).to(true)
+    end
+
+    it 'creates a record of the failure' do
+      expect { perform }
+        .to raise_error(StandardError, 'import_forced_to_fail')
+        .and change { tracker.reload.project.import_failures.count }.by(1)
+
+      failure = tracker.project.import_failures.last
+      expect(failure.exception_message).to eq('import_forced_to_fail')
+    end
+  end
+
+  it_behaves_like 'an idempotent worker' do
+    let(:job_args) { [tracker.id, user.id] }
+
+    it 'only starts one import when triggered multiple times' do
+      perform_multiple(job_args)
+
+      expect(tracker.reload.finished?).to be_truthy
+    end
+
+    it 'does not log any error when triggered multiple times' do
+      expect { perform_multiple(job_args) }.not_to change { tracker.reload.project.import_failures.count }
+    end
+  end
+
+  it_behaves_like 'worker with data consistency', described_class, data_consistency: :delayed
+end