From a309861a905c9aa6a7ba7d5fbd06823775604476 Mon Sep 17 00:00:00 2001
From: Aishwarya Subramanian <asubramanian@gitlab.com>
Date: Thu, 23 Jan 2020 22:12:00 -0600
Subject: [PATCH] Fix logic used to determine project export status

Currently, lock files are used to determine the state of a
project export. However, the lock files are not stored in a shared
volume, causing the export states to be reported incorrectly.

In order to fix this, we now store the export states in database
as a more reliable mechanism. The table used is project_export_jobs.
It stores the project_id, job_id and the current state.

In addition to existing states, also added are two new states
queued and regeneration_in_progress states. They are used to indicate
a job that's waiting to be started and the case when a project export
operation has been requested to be re-generated.

Failed jobs are re-tried 3 times, after which the state is updated
to failed.

To account for jobs that get stuck, a cron job runs every hour
to set the stuck jobs to failed state.

Closes https://gitlab.com/gitlab-org/gitlab/issues/32203
---
 app/finders/projects/export_job_finder.rb     | 29 +++++++
 app/models/project.rb                         | 23 +++--
 app/models/project_export_job.rb              | 26 ++++++
 app/workers/all_queues.yml                    |  7 ++
 .../concerns/project_export_options.rb        | 25 ++++++
 app/workers/project_export_worker.rb          |  9 +-
 app/workers/stuck_export_jobs_worker.rb       | 54 ++++++++++++
 .../unreleased/fix-export-state-logic.yml     |  5 ++
 config/initializers/1_settings.rb             |  3 +
 ...200311165635_create_project_export_jobs.rb | 19 ++++
 db/schema.rb                                  | 15 +++-
 doc/api/project_import_export.md              | 16 ++--
 ...om_template_export_import_strategy_spec.rb | 26 +++---
 spec/controllers/projects_controller_spec.rb  |  6 +-
 spec/factories/project_export_jobs.rb         |  8 ++
 .../projects/export_job_finder_spec.rb        | 51 +++++++++++
 .../public_api/v4/project/export_status.json  |  3 +-
 .../base_after_export_strategy_spec.rb        |  6 ++
 .../web_upload_strategy_spec.rb               |  6 ++
 spec/lib/gitlab/import_export/all_models.yml  |  1 +
 spec/models/project_export_job_spec.rb        | 19 ++++
 spec/models/project_spec.rb                   | 86 +++++++++++++++++++
 spec/requests/api/project_export_spec.rb      | 53 +++++++-----
 .../concerns/project_export_options_spec.rb   | 41 +++++++++
 spec/workers/project_export_worker_spec.rb    | 46 +++++++++-
 spec/workers/stuck_export_jobs_worker_spec.rb | 75 ++++++++++++++++
 26 files changed, 605 insertions(+), 53 deletions(-)
 create mode 100644 app/finders/projects/export_job_finder.rb
 create mode 100644 app/models/project_export_job.rb
 create mode 100644 app/workers/concerns/project_export_options.rb
 create mode 100644 app/workers/stuck_export_jobs_worker.rb
 create mode 100644 changelogs/unreleased/fix-export-state-logic.yml
 create mode 100644 db/migrate/20200311165635_create_project_export_jobs.rb
 create mode 100644 spec/factories/project_export_jobs.rb
 create mode 100644 spec/finders/projects/export_job_finder_spec.rb
 create mode 100644 spec/models/project_export_job_spec.rb
 create mode 100644 spec/workers/concerns/project_export_options_spec.rb
 create mode 100644 spec/workers/stuck_export_jobs_worker_spec.rb

diff --git a/app/finders/projects/export_job_finder.rb b/app/finders/projects/export_job_finder.rb
new file mode 100644
index 0000000000000..c26a7a3f1a67e
--- /dev/null
+++ b/app/finders/projects/export_job_finder.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Projects
+  class ExportJobFinder
+    InvalidExportJobStatusError = Class.new(StandardError)
+    attr_reader :project, :params
+
+    def initialize(project, params = {})
+      @project = project
+      @params = params
+    end
+
+    def execute
+      export_jobs = project.export_jobs
+      export_jobs = by_status(export_jobs)
+
+      export_jobs
+    end
+
+    private
+
+    def by_status(export_jobs)
+      return export_jobs unless params[:status]
+      raise InvalidExportJobStatusError, 'Invalid export job status' unless ProjectExportJob.state_machines[:status].states.map(&:name).include?(params[:status])
+
+      export_jobs.with_status(params[:status])
+    end
+  end
+end
diff --git a/app/models/project.rb b/app/models/project.rb
index daae6544ad5f8..890768ccf58c9 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -186,6 +186,7 @@ class Project < ApplicationRecord
 
   has_one :import_state, autosave: true, class_name: 'ProjectImportState', inverse_of: :project
   has_one :import_export_upload, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+  has_many :export_jobs, class_name: 'ProjectExportJob'
   has_one :project_repository, inverse_of: :project
   has_one :incident_management_setting, inverse_of: :project, class_name: 'IncidentManagement::ProjectIncidentManagementSetting'
   has_one :error_tracking_setting, inverse_of: :project, class_name: 'ErrorTracking::ProjectErrorTrackingSetting'
@@ -1850,10 +1851,12 @@ def export_path
   end
 
   def export_status
-    if export_in_progress?
+    if regeneration_in_progress?
+      :regeneration_in_progress
+    elsif export_enqueued?
+      :queued
+    elsif export_in_progress?
       :started
-    elsif after_export_in_progress?
-      :after_export_action
     elsif export_file_exists?
       :finished
     else
@@ -1862,11 +1865,19 @@ def export_status
   end
 
   def export_in_progress?
-    import_export_shared.active_export_count > 0
+    strong_memoize(:export_in_progress) do
+      ::Projects::ExportJobFinder.new(self, { status: :started }).execute.present?
+    end
+  end
+
+  def export_enqueued?
+    strong_memoize(:export_enqueued) do
+      ::Projects::ExportJobFinder.new(self, { status: :queued }).execute.present?
+    end
   end
 
-  def after_export_in_progress?
-    import_export_shared.after_export_in_progress?
+  def regeneration_in_progress?
+    (export_enqueued? || export_in_progress?) && export_file_exists?
   end
 
   def remove_exports
diff --git a/app/models/project_export_job.rb b/app/models/project_export_job.rb
new file mode 100644
index 0000000000000..c7fe3d7bc10e2
--- /dev/null
+++ b/app/models/project_export_job.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+class ProjectExportJob < ApplicationRecord
+  belongs_to :project
+
+  validates :project, :jid, :status, presence: true
+
+  state_machine :status, initial: :queued do
+    event :start do
+      transition [:queued] => :started
+    end
+
+    event :finish do
+      transition [:started] => :finished
+    end
+
+    event :fail_op do
+      transition [:queued, :started] => :failed
+    end
+
+    state :queued, value: 0
+    state :started, value: 1
+    state :finished, value: 2
+    state :failed, value: 3
+  end
+end
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index 890f38aa26b49..71f7c1bac3c39 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -234,6 +234,13 @@
   :resource_boundary: :cpu
   :weight: 1
   :idempotent: 
+- :name: cronjob:stuck_export_jobs
+  :feature_category: :importers
+  :has_external_dependencies: 
+  :urgency: :default
+  :resource_boundary: :cpu
+  :weight: 1
+  :idempotent: 
 - :name: cronjob:stuck_import_jobs
   :feature_category: :importers
   :has_external_dependencies: 
diff --git a/app/workers/concerns/project_export_options.rb b/app/workers/concerns/project_export_options.rb
new file mode 100644
index 0000000000000..e9318c1ba43c0
--- /dev/null
+++ b/app/workers/concerns/project_export_options.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module ProjectExportOptions
+  extend ActiveSupport::Concern
+
+  EXPORT_RETRY_COUNT = 3
+
+  included do
+    sidekiq_options retry: EXPORT_RETRY_COUNT, status_expiration: StuckExportJobsWorker::EXPORT_JOBS_EXPIRATION
+
+    # We mark the project export as failed once we have exhausted all retries
+    sidekiq_retries_exhausted do |job|
+      project = Project.find(job['args'][1])
+      # rubocop: disable CodeReuse/ActiveRecord
+      job = project.export_jobs.find_by(jid: job["jid"])
+      # rubocop: enable CodeReuse/ActiveRecord
+
+      if job&.fail_op
+        Sidekiq.logger.info "Job #{job['jid']} for project #{project.id} has been set to failed state"
+      else
+        Sidekiq.logger.error "Failed to set Job #{job['jid']} for project #{project.id} to failed state"
+      end
+    end
+  end
+end
diff --git a/app/workers/project_export_worker.rb b/app/workers/project_export_worker.rb
index eefba6d25c745..aaaf70f09b5c7 100644
--- a/app/workers/project_export_worker.rb
+++ b/app/workers/project_export_worker.rb
@@ -3,17 +3,24 @@
 class ProjectExportWorker # rubocop:disable Scalability/IdempotentWorker
   include ApplicationWorker
   include ExceptionBacktrace
+  include ProjectExportOptions
 
-  sidekiq_options retry: 3
   feature_category :importers
   worker_resource_boundary :memory
 
   def perform(current_user_id, project_id, after_export_strategy = {}, params = {})
     current_user = User.find(current_user_id)
     project = Project.find(project_id)
+    export_job = project.export_jobs.safe_find_or_create_by(jid: self.jid)
     after_export = build!(after_export_strategy)
 
+    export_job&.start
+
     ::Projects::ImportExport::ExportService.new(project, current_user, params).execute(after_export)
+
+    export_job&.finish
+  rescue ActiveRecord::RecordNotFound, Gitlab::ImportExport::AfterExportStrategyBuilder::StrategyNotFoundError => e
+    logger.error("Failed to export project #{project_id}: #{e.message}")
   end
 
   private
diff --git a/app/workers/stuck_export_jobs_worker.rb b/app/workers/stuck_export_jobs_worker.rb
new file mode 100644
index 0000000000000..6d8d60d2fc04d
--- /dev/null
+++ b/app/workers/stuck_export_jobs_worker.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+# rubocop:disable Scalability/IdempotentWorker
+class StuckExportJobsWorker
+  include ApplicationWorker
+  # rubocop:disable Scalability/CronWorkerContext
+  # This worker updates export states inline and does not schedule
+  # other jobs.
+  include CronjobQueue
+  # rubocop:enable Scalability/CronWorkerContext
+
+  feature_category :importers
+  worker_resource_boundary :cpu
+
+  EXPORT_JOBS_EXPIRATION = 6.hours.to_i
+
+  def perform
+    failed_jobs_count = mark_stuck_jobs_as_failed!
+
+    Gitlab::Metrics.add_event(:stuck_export_jobs,
+                              failed_jobs_count: failed_jobs_count)
+  end
+
+  private
+
+  # rubocop: disable CodeReuse/ActiveRecord
+  def mark_stuck_jobs_as_failed!
+    jids_and_ids = enqueued_exports.pluck(:jid, :id).to_h
+
+    completed_jids = Gitlab::SidekiqStatus.completed_jids(jids_and_ids.keys)
+    return unless completed_jids.any?
+
+    completed_ids = jids_and_ids.values_at(*completed_jids)
+
+    # We select the export states again, because they may have transitioned from
+    # started to finished while we were looking up their Sidekiq status.
+    completed_jobs = enqueued_exports.where(id: completed_ids)
+
+    Sidekiq.logger.info(
+      message: 'Marked stuck export jobs as failed',
+      job_ids: completed_jobs.map(&:jid)
+    )
+
+    completed_jobs.each do |job|
+      job.fail_op
+    end.count
+  end
+  # rubocop: enable CodeReuse/ActiveRecord
+
+  def enqueued_exports
+    ProjectExportJob.with_status([:started, :queued])
+  end
+end
+# rubocop:enable Scalability/IdempotentWorker
diff --git a/changelogs/unreleased/fix-export-state-logic.yml b/changelogs/unreleased/fix-export-state-logic.yml
new file mode 100644
index 0000000000000..7b4bc2186a0f3
--- /dev/null
+++ b/changelogs/unreleased/fix-export-state-logic.yml
@@ -0,0 +1,5 @@
+---
+title: Fix logic to determine project export state and add regeneration_in_progress state
+merge_request: 23664
+author:
+type: fixed
diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb
index fdf3ec67be762..f22ddc7f08179 100644
--- a/config/initializers/1_settings.rb
+++ b/config/initializers/1_settings.rb
@@ -453,6 +453,9 @@
 Settings.cron_jobs['stuck_import_jobs_worker'] ||= Settingslogic.new({})
 Settings.cron_jobs['stuck_import_jobs_worker']['cron'] ||= '15 * * * *'
 Settings.cron_jobs['stuck_import_jobs_worker']['job_class'] = 'StuckImportJobsWorker'
+Settings.cron_jobs['stuck_export_jobs_worker'] ||= Settingslogic.new({})
+Settings.cron_jobs['stuck_export_jobs_worker']['cron'] ||= '30 * * * *'
+Settings.cron_jobs['stuck_export_jobs_worker']['job_class'] = 'StuckExportJobsWorker'
 Settings.cron_jobs['gitlab_usage_ping_worker'] ||= Settingslogic.new({})
 Settings.cron_jobs['gitlab_usage_ping_worker']['cron'] ||= nil # This is dynamically loaded in the sidekiq initializer
 Settings.cron_jobs['gitlab_usage_ping_worker']['job_class'] = 'GitlabUsagePingWorker'
diff --git a/db/migrate/20200311165635_create_project_export_jobs.rb b/db/migrate/20200311165635_create_project_export_jobs.rb
new file mode 100644
index 0000000000000..026ad2cd771b4
--- /dev/null
+++ b/db/migrate/20200311165635_create_project_export_jobs.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class CreateProjectExportJobs < ActiveRecord::Migration[6.0]
+  DOWNTIME = false
+
+  def change
+    create_table :project_export_jobs do |t|
+      t.references :project, index: false, null: false, foreign_key: { on_delete: :cascade }
+      t.timestamps_with_timezone null: false
+      t.integer :status, limit: 2, null: false, default: 0
+      t.string :jid, limit: 100, null: false, unique: true
+
+      t.index [:project_id, :jid]
+      t.index [:jid], unique: true
+      t.index [:status]
+      t.index [:project_id, :status]
+    end
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 3128317ec99d2..52bb18dda688e 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema.define(version: 2020_03_10_135823) do
+ActiveRecord::Schema.define(version: 2020_03_11_165635) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "pg_trgm"
@@ -3242,6 +3242,18 @@
     t.string "organization_name"
   end
 
+  create_table "project_export_jobs", force: :cascade do |t|
+    t.bigint "project_id", null: false
+    t.datetime_with_timezone "created_at", null: false
+    t.datetime_with_timezone "updated_at", null: false
+    t.integer "status", limit: 2, default: 0, null: false
+    t.string "jid", limit: 100, null: false
+    t.index ["jid"], name: "index_project_export_jobs_on_jid", unique: true
+    t.index ["project_id", "jid"], name: "index_project_export_jobs_on_project_id_and_jid"
+    t.index ["project_id", "status"], name: "index_project_export_jobs_on_project_id_and_status"
+    t.index ["status"], name: "index_project_export_jobs_on_status"
+  end
+
   create_table "project_feature_usages", primary_key: "project_id", id: :integer, default: nil, force: :cascade do |t|
     t.datetime "jira_dvcs_cloud_last_sync_at"
     t.datetime "jira_dvcs_server_last_sync_at"
@@ -5017,6 +5029,7 @@
   add_foreign_key "project_deploy_tokens", "deploy_tokens", on_delete: :cascade
   add_foreign_key "project_deploy_tokens", "projects", on_delete: :cascade
   add_foreign_key "project_error_tracking_settings", "projects", on_delete: :cascade
+  add_foreign_key "project_export_jobs", "projects", on_delete: :cascade
   add_foreign_key "project_feature_usages", "projects", on_delete: :cascade
   add_foreign_key "project_features", "projects", name: "fk_18513d9b92", on_delete: :cascade
   add_foreign_key "project_group_links", "projects", name: "fk_daa8cee94c", on_delete: :cascade
diff --git a/doc/api/project_import_export.md b/doc/api/project_import_export.md
index d1aaa01d37c72..476abc188353e 100644
--- a/doc/api/project_import_export.md
+++ b/doc/api/project_import_export.md
@@ -61,14 +61,20 @@ curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/ap
 Status can be one of:
 
 - `none`
+- `queued`
 - `started`
-- `after_export_action`
 - `finished`
+- `regeneration_in_progress`
 
-The `after_export_action` state represents that the export process has been completed successfully and
-the platform is performing some actions on the resulted file. For example, sending
-an email notifying the user to download the file, uploading the exported file
-to a web server, etc.
+`queued` state represents the request for export is received, and is currently in the queue to be processed.
+
+The `started` state represents that the export process has started and is currently in progress.
+It includes the process of exporting, actions performed on the resultant file such as sending
+an email notifying the user to download the file, uploading the exported file to a web server, etc.
+
+`finished` state is after the export process has completed and the user has been notified.
+
+`regeneration_in_progress` is when an export file is available to download, and a request to generate a new export is in process.
 
 `_links` are only present when export has finished.
 
diff --git a/ee/spec/lib/ee/gitlab/import_export/after_export_strategies/custom_template_export_import_strategy_spec.rb b/ee/spec/lib/ee/gitlab/import_export/after_export_strategies/custom_template_export_import_strategy_spec.rb
index d9d9f6376eca2..91aa683dc1190 100644
--- a/ee/spec/lib/ee/gitlab/import_export/after_export_strategies/custom_template_export_import_strategy_spec.rb
+++ b/ee/spec/lib/ee/gitlab/import_export/after_export_strategies/custom_template_export_import_strategy_spec.rb
@@ -3,19 +3,8 @@
 require 'spec_helper'
 
 describe EE::Gitlab::ImportExport::AfterExportStrategies::CustomTemplateExportImportStrategy do
-  let!(:project_template) { create(:project, :repository, :with_export) }
-  let(:project) { create(:project, :import_scheduled, import_type: 'gitlab_custom_project_template') }
-  let(:user) { build(:user) }
-  let(:repository_import_worker) { RepositoryImportWorker.new }
-
   subject { described_class.new(export_into_project_id: project.id) }
 
-  before do
-    stub_licensed_features(custom_project_templates: true)
-    allow(RepositoryImportWorker).to receive(:new).and_return(repository_import_worker)
-    allow(repository_import_worker).to receive(:perform)
-  end
-
   describe 'validations' do
     it 'export_into_project_id must be present' do
       expect(described_class.new(export_into_project_id: nil)).to be_invalid
@@ -24,6 +13,21 @@
   end
 
   describe '#execute' do
+    before do
+      allow_next_instance_of(ProjectExportWorker) do |job|
+        allow(job).to receive(:jid).and_return(SecureRandom.hex(8))
+      end
+
+      stub_licensed_features(custom_project_templates: true)
+      allow(RepositoryImportWorker).to receive(:new).and_return(repository_import_worker)
+      allow(repository_import_worker).to receive(:perform)
+    end
+
+    let!(:project_template) { create(:project, :repository, :with_export) }
+    let(:project) { create(:project, :import_scheduled, import_type: 'gitlab_custom_project_template') }
+    let(:user) { build(:user) }
+    let(:repository_import_worker) { RepositoryImportWorker.new }
+
     it 'updates the project import_source with the path to import' do
       file = fixture_file_upload('spec/fixtures/project_export.tar.gz')
 
diff --git a/spec/controllers/projects_controller_spec.rb b/spec/controllers/projects_controller_spec.rb
index 67e24841dee86..53a57937e9bcc 100644
--- a/spec/controllers/projects_controller_spec.rb
+++ b/spec/controllers/projects_controller_spec.rb
@@ -1140,7 +1140,7 @@ def update_project(**parameters)
       end
 
       it 'prevents requesting project export' do
-        get action, params: { namespace_id: project.namespace, id: project }
+        post action, params: { namespace_id: project.namespace, id: project }
 
         expect(flash[:alert]).to eq('This endpoint has been requested too many times. Try again later.')
         expect(response).to have_gitlab_http_status(:found)
@@ -1152,7 +1152,7 @@ def update_project(**parameters)
 
       context 'when project export is enabled' do
         it 'returns 302' do
-          get action, params: { namespace_id: project.namespace, id: project }
+          post action, params: { namespace_id: project.namespace, id: project }
 
           expect(response).to have_gitlab_http_status(:found)
         end
@@ -1164,7 +1164,7 @@ def update_project(**parameters)
         end
 
         it 'returns 404' do
-          get action, params: { namespace_id: project.namespace, id: project }
+          post action, params: { namespace_id: project.namespace, id: project }
 
           expect(response).to have_gitlab_http_status(:not_found)
         end
diff --git a/spec/factories/project_export_jobs.rb b/spec/factories/project_export_jobs.rb
new file mode 100644
index 0000000000000..b2666555ea807
--- /dev/null
+++ b/spec/factories/project_export_jobs.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+  factory :project_export_job do
+    project
+    jid { SecureRandom.hex(8) }
+  end
+end
diff --git a/spec/finders/projects/export_job_finder_spec.rb b/spec/finders/projects/export_job_finder_spec.rb
new file mode 100644
index 0000000000000..31b68717d13e7
--- /dev/null
+++ b/spec/finders/projects/export_job_finder_spec.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Projects::ExportJobFinder do
+  let(:project) { create(:project) }
+  let(:project_export_job1) { create(:project_export_job, project: project) }
+  let(:project_export_job2) { create(:project_export_job, project: project) }
+
+  describe '#execute' do
+    subject { described_class.new(project, params).execute }
+
+    context 'when queried for a project' do
+      let(:params) { {} }
+
+      it 'scopes to the project' do
+        expect(subject).to contain_exactly(
+          project_export_job1, project_export_job2
+        )
+      end
+    end
+
+    context 'when queried by job id' do
+      let(:params) { { jid: project_export_job1.jid } }
+
+      it 'filters records' do
+        expect(subject).to contain_exactly(project_export_job1)
+      end
+    end
+
+    context 'when queried by status' do
+      let(:params) { { status: :started } }
+
+      before do
+        project_export_job2.start!
+      end
+
+      it 'filters records' do
+        expect(subject).to contain_exactly(project_export_job2)
+      end
+    end
+
+    context 'when queried by invalid status' do
+      let(:params) { { status: '1234ad' } }
+
+      it 'raises exception' do
+        expect { subject }.to raise_error(described_class::InvalidExportJobStatusError, 'Invalid export job status')
+      end
+    end
+  end
+end
diff --git a/spec/fixtures/api/schemas/public_api/v4/project/export_status.json b/spec/fixtures/api/schemas/public_api/v4/project/export_status.json
index 81c8815caf6f1..fd35ba34b49fb 100644
--- a/spec/fixtures/api/schemas/public_api/v4/project/export_status.json
+++ b/spec/fixtures/api/schemas/public_api/v4/project/export_status.json
@@ -13,9 +13,10 @@
           "type": "string",
           "enum": [
             "none",
+            "queued",
             "started",
             "finished",
-            "after_export_action"
+            "regeneration_in_progress"
           ]
         }
       }
diff --git a/spec/lib/gitlab/import_export/after_export_strategies/base_after_export_strategy_spec.rb b/spec/lib/gitlab/import_export/after_export_strategies/base_after_export_strategy_spec.rb
index 86ceb97b2503c..1631de393b505 100644
--- a/spec/lib/gitlab/import_export/after_export_strategies/base_after_export_strategy_spec.rb
+++ b/spec/lib/gitlab/import_export/after_export_strategies/base_after_export_strategy_spec.rb
@@ -3,6 +3,12 @@
 require 'spec_helper'
 
 describe Gitlab::ImportExport::AfterExportStrategies::BaseAfterExportStrategy do
+  before do
+    allow_next_instance_of(ProjectExportWorker) do |job|
+      allow(job).to receive(:jid).and_return(SecureRandom.hex(8))
+    end
+  end
+
   let!(:service) { described_class.new }
   let!(:project) { create(:project, :with_export) }
   let(:shared) { project.import_export_shared }
diff --git a/spec/lib/gitlab/import_export/after_export_strategies/web_upload_strategy_spec.rb b/spec/lib/gitlab/import_export/after_export_strategies/web_upload_strategy_spec.rb
index 95c47d15f8fdf..7792daed99cba 100644
--- a/spec/lib/gitlab/import_export/after_export_strategies/web_upload_strategy_spec.rb
+++ b/spec/lib/gitlab/import_export/after_export_strategies/web_upload_strategy_spec.rb
@@ -5,6 +5,12 @@
 describe Gitlab::ImportExport::AfterExportStrategies::WebUploadStrategy do
   include StubRequests
 
+  before do
+    allow_next_instance_of(ProjectExportWorker) do |job|
+      allow(job).to receive(:jid).and_return(SecureRandom.hex(8))
+    end
+  end
+
   let(:example_url) { 'http://www.example.com' }
   let(:strategy) { subject.new(url: example_url, http_method: 'post') }
   let!(:project) { create(:project, :with_export) }
diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml
index e579c8474b70d..37b3e4a4a22fb 100644
--- a/spec/lib/gitlab/import_export/all_models.yml
+++ b/spec/lib/gitlab/import_export/all_models.yml
@@ -469,6 +469,7 @@ project:
 - autoclose_referenced_issues
 - status_page_setting
 - requirements
+- export_jobs
 award_emoji:
 - awardable
 - user
diff --git a/spec/models/project_export_job_spec.rb b/spec/models/project_export_job_spec.rb
new file mode 100644
index 0000000000000..dc39d0e401d8a
--- /dev/null
+++ b/spec/models/project_export_job_spec.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe ProjectExportJob, type: :model do
+  let(:project) { create(:project) }
+  let!(:job1) { create(:project_export_job, project: project, status: 0) }
+  let!(:job2) { create(:project_export_job, project: project, status: 2) }
+
+  describe 'associations' do
+    it { expect(job1).to belong_to(:project) }
+  end
+
+  describe 'validations' do
+    it { expect(job1).to validate_presence_of(:project) }
+    it { expect(job1).to validate_presence_of(:jid) }
+    it { expect(job1).to validate_presence_of(:status) }
+  end
+end
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index 782e526b69d3c..6d9b46c9941b8 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -3957,6 +3957,12 @@ def enable_lfs
   describe '#remove_export' do
     let(:project) { create(:project, :with_export) }
 
+    before do
+      allow_next_instance_of(ProjectExportWorker) do |job|
+        allow(job).to receive(:jid).and_return(SecureRandom.hex(8))
+      end
+    end
+
     it 'removes the export' do
       project.remove_exports
 
@@ -5813,6 +5819,86 @@ def enable_lfs
     end
   end
 
+  describe '#add_export_job' do
+    context 'if not already present' do
+      it 'starts project export job' do
+        user = create(:user)
+        project = build(:project)
+
+        expect(ProjectExportWorker).to receive(:perform_async).with(user.id, project.id, nil, {})
+
+        project.add_export_job(current_user: user)
+      end
+    end
+  end
+
+  describe '#export_in_progress?' do
+    let(:project) { build(:project) }
+    let!(:project_export_job ) { create(:project_export_job, project: project) }
+
+    context 'when project export is enqueued' do
+      it { expect(project.export_in_progress?).to be false }
+    end
+
+    context 'when project export is in progress' do
+      before do
+        project_export_job.start!
+      end
+
+      it { expect(project.export_in_progress?).to be true }
+    end
+
+    context 'when project export is completed' do
+      before do
+        finish_job(project_export_job)
+      end
+
+      it { expect(project.export_in_progress?).to be false }
+    end
+  end
+
+  describe '#export_status' do
+    let(:project) { build(:project) }
+    let!(:project_export_job ) { create(:project_export_job, project: project) }
+
+    context 'when project export is enqueued' do
+      it { expect(project.export_status).to eq :queued }
+    end
+
+    context 'when project export is in progress' do
+      before do
+        project_export_job.start!
+      end
+
+      it { expect(project.export_status).to eq :started }
+    end
+
+    context 'when project export is completed' do
+      before do
+        finish_job(project_export_job)
+        allow(project).to receive(:export_file).and_return(double(ImportExportUploader, file: 'exists.zip'))
+      end
+
+      it { expect(project.export_status).to eq :finished }
+    end
+
+    context 'when project export is being regenerated' do
+      let!(:new_project_export_job ) { create(:project_export_job, project: project) }
+
+      before do
+        finish_job(project_export_job)
+        allow(project).to receive(:export_file).and_return(double(ImportExportUploader, file: 'exists.zip'))
+      end
+
+      it { expect(project.export_status).to eq :regeneration_in_progress }
+    end
+  end
+
+  def finish_job(export_job)
+    export_job.start
+    export_job.finish
+  end
+
   def rugged_config
     rugged_repo(project.repository).config
   end
diff --git a/spec/requests/api/project_export_spec.rb b/spec/requests/api/project_export_spec.rb
index d5c822385dae7..859a3cca44f46 100644
--- a/spec/requests/api/project_export_spec.rb
+++ b/spec/requests/api/project_export_spec.rb
@@ -27,12 +27,9 @@
 
   before do
     allow_any_instance_of(Gitlab::ImportExport).to receive(:storage_path).and_return(export_path)
-
-    # simulate exporting work directory
-    FileUtils.mkdir_p File.join(project_started.export_path, 'securerandom-hex')
-
-    # simulate in after export action
-    FileUtils.touch File.join(project_after_export.import_export_shared.lock_files_path, SecureRandom.hex)
+    allow_next_instance_of(ProjectExportWorker) do |job|
+      allow(job).to receive(:jid).and_return(SecureRandom.hex(8))
+    end
   end
 
   after do
@@ -82,28 +79,42 @@
         expect(json_response['export_status']).to eq('none')
       end
 
-      it 'is started' do
-        get api(path_started, user)
+      context 'when project export has started' do
+        before do
+          create(:project_export_job, project: project_started, status: 1)
+        end
 
-        expect(response).to have_gitlab_http_status(:ok)
-        expect(response).to match_response_schema('public_api/v4/project/export_status')
-        expect(json_response['export_status']).to eq('started')
+        it 'returns status started' do
+          get api(path_started, user)
+
+          expect(response).to have_gitlab_http_status(:ok)
+          expect(response).to match_response_schema('public_api/v4/project/export_status')
+          expect(json_response['export_status']).to eq('started')
+        end
       end
 
-      it 'is after_export' do
-        get api(path_after_export, user)
+      context 'when project export has finished' do
+        it 'returns status finished' do
+          get api(path_finished, user)
 
-        expect(response).to have_gitlab_http_status(:ok)
-        expect(response).to match_response_schema('public_api/v4/project/export_status')
-        expect(json_response['export_status']).to eq('after_export_action')
+          expect(response).to have_gitlab_http_status(:ok)
+          expect(response).to match_response_schema('public_api/v4/project/export_status')
+          expect(json_response['export_status']).to eq('finished')
+        end
       end
 
-      it 'is finished' do
-        get api(path_finished, user)
+      context 'when project export is being regenerated' do
+        before do
+          create(:project_export_job, project: project_finished, status: 1)
+        end
+
+        it 'returns status regeneration_in_progress' do
+          get api(path_finished, user)
 
-        expect(response).to have_gitlab_http_status(:ok)
-        expect(response).to match_response_schema('public_api/v4/project/export_status')
-        expect(json_response['export_status']).to eq('finished')
+          expect(response).to have_gitlab_http_status(:ok)
+          expect(response).to match_response_schema('public_api/v4/project/export_status')
+          expect(json_response['export_status']).to eq('regeneration_in_progress')
+        end
       end
     end
 
diff --git a/spec/workers/concerns/project_export_options_spec.rb b/spec/workers/concerns/project_export_options_spec.rb
new file mode 100644
index 0000000000000..985afaaf11e83
--- /dev/null
+++ b/spec/workers/concerns/project_export_options_spec.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe ProjectExportOptions do
+  let(:project) { create(:project) }
+  let(:project_export_job) { create(:project_export_job, project: project, jid: '123', status: 1) }
+  let(:job) { { 'args' => [project.owner.id, project.id, nil, nil], 'jid' => '123' } }
+  let(:worker_class) do
+    Class.new do
+      include Sidekiq::Worker
+      include ProjectExportOptions
+    end
+  end
+
+  it 'sets default retry limit' do
+    expect(worker_class.sidekiq_options['retry']).to eq(ProjectExportOptions::EXPORT_RETRY_COUNT)
+  end
+
+  it 'sets default status expiration' do
+    expect(worker_class.sidekiq_options['status_expiration']).to eq(StuckExportJobsWorker::EXPORT_JOBS_EXPIRATION)
+  end
+
+  describe '.sidekiq_retries_exhausted' do
+    it 'marks status as failed' do
+      expect { worker_class.sidekiq_retries_exhausted_block.call(job) }.to change { project_export_job.reload.status }.from(1).to(3)
+    end
+
+    context 'when status update fails' do
+      before do
+        project_export_job.update(status: 2)
+      end
+
+      it 'logs an error' do
+        expect(Sidekiq.logger).to receive(:error).with("Failed to set Job #{job['jid']} for project #{project.id} to failed state")
+
+        worker_class.sidekiq_retries_exhausted_block.call(job)
+      end
+    end
+  end
+end
diff --git a/spec/workers/project_export_worker_spec.rb b/spec/workers/project_export_worker_spec.rb
index 8065087796c1e..d0d52e0df2d43 100644
--- a/spec/workers/project_export_worker_spec.rb
+++ b/spec/workers/project_export_worker_spec.rb
@@ -9,21 +9,59 @@
   subject { described_class.new }
 
   describe '#perform' do
+    before do
+      allow_next_instance_of(described_class) do |job|
+        allow(job).to receive(:jid).and_return(SecureRandom.hex(8))
+      end
+    end
+
     context 'when it succeeds' do
       it 'calls the ExportService' do
         expect_any_instance_of(::Projects::ImportExport::ExportService).to receive(:execute)
 
         subject.perform(user.id, project.id, { 'klass' => 'Gitlab::ImportExport::AfterExportStrategies::DownloadNotificationStrategy' })
       end
+
+      context 'export job' do
+        before do
+          allow_any_instance_of(::Projects::ImportExport::ExportService).to receive(:execute)
+        end
+
+        it 'creates an export job record for the project' do
+          expect { subject.perform(user.id, project.id, {}) }.to change { project.export_jobs.count }.from(0).to(1)
+        end
+
+        it 'sets the export job status to started' do
+          expect_next_instance_of(ProjectExportJob) do |job|
+            expect(job).to receive(:start)
+          end
+
+          subject.perform(user.id, project.id, {})
+        end
+
+        it 'sets the export job status to finished' do
+          expect_next_instance_of(ProjectExportJob) do |job|
+            expect(job).to receive(:finish)
+          end
+
+          subject.perform(user.id, project.id, {})
+        end
+      end
     end
 
     context 'when it fails' do
-      it 'raises an exception when params are invalid' do
+      it 'does not raise an exception when strategy is invalid' do
         expect_any_instance_of(::Projects::ImportExport::ExportService).not_to receive(:execute)
 
-        expect { subject.perform(1234, project.id, {}) }.to raise_exception(ActiveRecord::RecordNotFound)
-        expect { subject.perform(user.id, 1234, {}) }.to raise_exception(ActiveRecord::RecordNotFound)
-        expect { subject.perform(user.id, project.id, { 'klass' => 'Whatever' }) }.to raise_exception(Gitlab::ImportExport::AfterExportStrategyBuilder::StrategyNotFoundError)
+        expect { subject.perform(user.id, project.id, { 'klass' => 'Whatever' }) }.not_to raise_error
+      end
+
+      it 'does not raise error when project cannot be found' do
+        expect { subject.perform(user.id, -234, {}) }.not_to raise_error
+      end
+
+      it 'does not raise error when user cannot be found' do
+        expect { subject.perform(-863, project.id, {}) }.not_to raise_error
       end
     end
   end
diff --git a/spec/workers/stuck_export_jobs_worker_spec.rb b/spec/workers/stuck_export_jobs_worker_spec.rb
new file mode 100644
index 0000000000000..fc5758fdadf88
--- /dev/null
+++ b/spec/workers/stuck_export_jobs_worker_spec.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe StuckExportJobsWorker do
+  let(:worker) { described_class.new }
+
+  shared_examples 'project export job detection' do
+    context 'when the job has completed' do
+      context 'when the export status was already updated' do
+        before do
+          allow(Gitlab::SidekiqStatus).to receive(:completed_jids) do
+            project_export_job.start
+            project_export_job.finish
+
+            [project_export_job.jid]
+          end
+        end
+
+        it 'does not mark the export as failed' do
+          worker.perform
+
+          expect(project_export_job.reload.finished?).to be true
+        end
+      end
+
+      context 'when the export status was not updated' do
+        before do
+          allow(Gitlab::SidekiqStatus).to receive(:completed_jids) do
+            project_export_job.start
+
+            [project_export_job.jid]
+          end
+        end
+
+        it 'marks the project as failed' do
+          worker.perform
+
+          expect(project_export_job.reload.failed?).to be true
+        end
+      end
+
+      context 'when the job is not in queue and db record in queued state' do
+        before do
+          allow(Gitlab::SidekiqStatus).to receive(:completed_jids).and_return([project_export_job.jid])
+        end
+
+        it 'marks the project as failed' do
+          expect(project_export_job.queued?).to be true
+
+          worker.perform
+
+          expect(project_export_job.reload.failed?).to be true
+        end
+      end
+    end
+
+    context 'when the job is running in Sidekiq' do
+      before do
+        allow(Gitlab::SidekiqStatus).to receive(:completed_jids).and_return([])
+      end
+
+      it 'does not mark the project export as failed' do
+        expect { worker.perform }.not_to change { project_export_job.reload.status }
+      end
+    end
+  end
+
+  describe 'with started export status' do
+    it_behaves_like 'project export job detection' do
+      let(:project) { create(:project) }
+      let!(:project_export_job) { create(:project_export_job, project: project, jid: '123') }
+    end
+  end
+end
-- 
GitLab