Merge branch '54117-transactional-rebase' into 'master'
Allow asynchronous rebase operations to be monitored Closes #54117 See merge request gitlab-org/gitlab-ce!29940
This commit is contained in:
commit
eaa6edbff3
15 changed files with 191 additions and 41 deletions
|
@ -201,7 +201,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
|
|||
end
|
||||
|
||||
def rebase
|
||||
RebaseWorker.perform_async(@merge_request.id, current_user.id)
|
||||
@merge_request.rebase_async(current_user.id)
|
||||
|
||||
head :ok
|
||||
end
|
||||
|
|
|
@ -223,7 +223,13 @@ class MergeRequest < ApplicationRecord
|
|||
end
|
||||
|
||||
def rebase_in_progress?
|
||||
strong_memoize(:rebase_in_progress) do
|
||||
(rebase_jid.present? && Gitlab::SidekiqStatus.running?(rebase_jid)) ||
|
||||
gitaly_rebase_in_progress?
|
||||
end
|
||||
|
||||
# TODO: remove the Gitaly lookup after v12.1, when rebase_jid will be reliable
|
||||
def gitaly_rebase_in_progress?
|
||||
strong_memoize(:gitaly_rebase_in_progress) do
|
||||
# The source project can be deleted
|
||||
next false unless source_project
|
||||
|
||||
|
@ -389,6 +395,26 @@ class MergeRequest < ApplicationRecord
|
|||
update_column(:merge_jid, jid)
|
||||
end
|
||||
|
||||
# Set off a rebase asynchronously, atomically updating the `rebase_jid` of
|
||||
# the MR so that the status of the operation can be tracked.
|
||||
def rebase_async(user_id)
|
||||
transaction do
|
||||
lock!
|
||||
|
||||
raise ActiveRecord::StaleObjectError if !open? || rebase_in_progress?
|
||||
|
||||
# Although there is a race between setting rebase_jid here and clearing it
|
||||
# in the RebaseWorker, it can't do any harm since we check both that the
|
||||
# attribute is set *and* that the sidekiq job is still running. So a JID
|
||||
# for a completed RebaseWorker is equivalent to a nil JID.
|
||||
jid = Sidekiq::Worker.skipping_transaction_check do
|
||||
RebaseWorker.perform_async(id, user_id)
|
||||
end
|
||||
|
||||
update_column(:rebase_jid, jid)
|
||||
end
|
||||
end
|
||||
|
||||
def merge_participants
|
||||
participants = [author]
|
||||
|
||||
|
|
|
@ -15,7 +15,7 @@ module MergeRequests
|
|||
end
|
||||
|
||||
def rebase
|
||||
if merge_request.rebase_in_progress?
|
||||
if merge_request.gitaly_rebase_in_progress?
|
||||
log_error('Rebase task canceled: Another rebase is already in progress', save_message_on_model: true)
|
||||
return false
|
||||
end
|
||||
|
@ -27,6 +27,8 @@ module MergeRequests
|
|||
log_error(REBASE_ERROR, save_message_on_model: true)
|
||||
log_error(e.message)
|
||||
false
|
||||
ensure
|
||||
merge_request.update_column(:rebase_jid, nil)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# The RebaseWorker must be wrapped in important concurrency code, so should only
|
||||
# be scheduled via MergeRequest#rebase_async
|
||||
class RebaseWorker
|
||||
include ApplicationWorker
|
||||
|
||||
|
|
5
changelogs/unreleased/54117-transactional-rebase.yml
Normal file
5
changelogs/unreleased/54117-transactional-rebase.yml
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Allow asynchronous rebase operations to be monitored
|
||||
merge_request: 29940
|
||||
author:
|
||||
type: fixed
|
|
@ -0,0 +1,9 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddMergeRequestRebaseJid < ActiveRecord::Migration[5.1]
|
||||
DOWNTIME = false
|
||||
|
||||
def change
|
||||
add_column :merge_requests, :rebase_jid, :string
|
||||
end
|
||||
end
|
|
@ -1989,6 +1989,7 @@ ActiveRecord::Schema.define(version: 20190628185004) do
|
|||
t.boolean "allow_maintainer_to_push"
|
||||
t.integer "state_id", limit: 2
|
||||
t.integer "approvals_before_merge"
|
||||
t.string "rebase_jid"
|
||||
t.index ["assignee_id"], name: "index_merge_requests_on_assignee_id", using: :btree
|
||||
t.index ["author_id"], name: "index_merge_requests_on_author_id", using: :btree
|
||||
t.index ["created_at"], name: "index_merge_requests_on_created_at", using: :btree
|
||||
|
|
|
@ -1485,8 +1485,14 @@ PUT /projects/:id/merge_requests/:merge_request_iid/rebase
|
|||
curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/projects/76/merge_requests/1/rebase
|
||||
```
|
||||
|
||||
This is an asynchronous request. The API will return an empty `202 Accepted`
|
||||
response if the request is enqueued successfully.
|
||||
This is an asynchronous request. The API will return a `202 Accepted` response
|
||||
if the request is enqueued successfully, with a response containing:
|
||||
|
||||
```json
|
||||
{
|
||||
"rebase_in_progress": true
|
||||
}
|
||||
```
|
||||
|
||||
You can poll the [Get single MR](#get-single-mr) endpoint with the
|
||||
`include_rebase_in_progress` parameter to check the status of the
|
||||
|
|
|
@ -52,7 +52,10 @@ module API
|
|||
rack_response({ 'message' => '404 Not found' }.to_json, 404)
|
||||
end
|
||||
|
||||
rescue_from ::Gitlab::ExclusiveLeaseHelpers::FailedToObtainLockError do
|
||||
rescue_from(
|
||||
::ActiveRecord::StaleObjectError,
|
||||
::Gitlab::ExclusiveLeaseHelpers::FailedToObtainLockError
|
||||
) do
|
||||
rack_response({ 'message' => '409 Conflict: Resource lock' }.to_json, 409)
|
||||
end
|
||||
|
||||
|
|
|
@ -429,9 +429,10 @@ module API
|
|||
|
||||
authorize_push_to_merge_request!(merge_request)
|
||||
|
||||
RebaseWorker.perform_async(merge_request.id, current_user.id)
|
||||
merge_request.rebase_async(current_user.id)
|
||||
|
||||
status :accepted
|
||||
present rebase_in_progress: merge_request.rebase_in_progress?
|
||||
end
|
||||
|
||||
desc 'List issues that will be closed on merge' do
|
||||
|
|
|
@ -160,6 +160,7 @@ excluded_attributes:
|
|||
- :milestone_id
|
||||
- :ref_fetched
|
||||
- :merge_jid
|
||||
- :rebase_jid
|
||||
- :latest_merge_request_diff_id
|
||||
award_emoji:
|
||||
- :awardable_id
|
||||
|
|
|
@ -53,14 +53,14 @@ module Gitlab
|
|||
self.num_running(job_ids).zero?
|
||||
end
|
||||
|
||||
# Returns true if the given job is running
|
||||
# Returns true if the given job is running or enqueued.
|
||||
#
|
||||
# job_id - The Sidekiq job ID to check.
|
||||
def self.running?(job_id)
|
||||
num_running([job_id]) > 0
|
||||
end
|
||||
|
||||
# Returns the number of jobs that are running.
|
||||
# Returns the number of jobs that are running or enqueued.
|
||||
#
|
||||
# job_ids - The Sidekiq job IDs to check.
|
||||
def self.num_running(job_ids)
|
||||
|
@ -81,7 +81,7 @@ module Gitlab
|
|||
# job_ids - The Sidekiq job IDs to check.
|
||||
#
|
||||
# Returns an array of true or false indicating job completion.
|
||||
# true = job is still running
|
||||
# true = job is still running or enqueued
|
||||
# false = job completed
|
||||
def self.job_status(job_ids)
|
||||
keys = job_ids.map { |jid| key_for(jid) }
|
||||
|
|
|
@ -7,6 +7,8 @@ describe MergeRequest do
|
|||
include ProjectForksHelper
|
||||
include ReactiveCachingHelpers
|
||||
|
||||
using RSpec::Parameterized::TableSyntax
|
||||
|
||||
subject { create(:merge_request) }
|
||||
|
||||
describe 'associations' do
|
||||
|
@ -1996,6 +1998,47 @@ describe MergeRequest do
|
|||
end
|
||||
end
|
||||
|
||||
describe '#rebase_async' do
|
||||
let(:merge_request) { create(:merge_request) }
|
||||
let(:user_id) { double(:user_id) }
|
||||
let(:rebase_jid) { 'rebase-jid' }
|
||||
|
||||
subject(:execute) { merge_request.rebase_async(user_id) }
|
||||
|
||||
it 'atomically enqueues a RebaseWorker job and updates rebase_jid' do
|
||||
expect(RebaseWorker)
|
||||
.to receive(:perform_async)
|
||||
.with(merge_request.id, user_id)
|
||||
.and_return(rebase_jid)
|
||||
|
||||
expect(merge_request).to receive(:lock!).and_call_original
|
||||
|
||||
execute
|
||||
|
||||
expect(merge_request.rebase_jid).to eq(rebase_jid)
|
||||
end
|
||||
|
||||
it 'refuses to enqueue a job if a rebase is in progress' do
|
||||
merge_request.update_column(:rebase_jid, rebase_jid)
|
||||
|
||||
expect(RebaseWorker).not_to receive(:perform_async)
|
||||
expect(Gitlab::SidekiqStatus)
|
||||
.to receive(:running?)
|
||||
.with(rebase_jid)
|
||||
.and_return(true)
|
||||
|
||||
expect { execute }.to raise_error(ActiveRecord::StaleObjectError)
|
||||
end
|
||||
|
||||
it 'refuses to enqueue a job if the MR is not open' do
|
||||
merge_request.update_column(:state, 'foo')
|
||||
|
||||
expect(RebaseWorker).not_to receive(:perform_async)
|
||||
|
||||
expect { execute }.to raise_error(ActiveRecord::StaleObjectError)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#mergeable?' do
|
||||
let(:project) { create(:project) }
|
||||
|
||||
|
@ -2946,40 +2989,64 @@ describe MergeRequest do
|
|||
end
|
||||
|
||||
describe '#rebase_in_progress?' do
|
||||
shared_examples 'checking whether a rebase is in progress' do
|
||||
let(:repo_path) do
|
||||
Gitlab::GitalyClient::StorageSettings.allow_disk_access do
|
||||
subject.source_project.repository.path
|
||||
end
|
||||
where(:rebase_jid, :jid_valid, :result) do
|
||||
'foo' | true | true
|
||||
'foo' | false | false
|
||||
'' | true | false
|
||||
nil | true | false
|
||||
end
|
||||
|
||||
with_them do
|
||||
let(:merge_request) { create(:merge_request) }
|
||||
|
||||
subject { merge_request.rebase_in_progress? }
|
||||
|
||||
it do
|
||||
# Stub out the legacy gitaly implementation
|
||||
allow(merge_request).to receive(:gitaly_rebase_in_progress?) { false }
|
||||
|
||||
allow(Gitlab::SidekiqStatus).to receive(:running?).with(rebase_jid) { jid_valid }
|
||||
|
||||
merge_request.rebase_jid = rebase_jid
|
||||
|
||||
is_expected.to eq(result)
|
||||
end
|
||||
let(:rebase_path) { File.join(repo_path, "gitlab-worktree", "rebase-#{subject.id}") }
|
||||
end
|
||||
end
|
||||
|
||||
before do
|
||||
system(*%W(#{Gitlab.config.git.bin_path} -C #{repo_path} worktree add --detach #{rebase_path} master))
|
||||
describe '#gitaly_rebase_in_progress?' do
|
||||
let(:repo_path) do
|
||||
Gitlab::GitalyClient::StorageSettings.allow_disk_access do
|
||||
subject.source_project.repository.path
|
||||
end
|
||||
end
|
||||
let(:rebase_path) { File.join(repo_path, "gitlab-worktree", "rebase-#{subject.id}") }
|
||||
|
||||
it 'returns true when there is a current rebase directory' do
|
||||
expect(subject.rebase_in_progress?).to be_truthy
|
||||
end
|
||||
before do
|
||||
system(*%W(#{Gitlab.config.git.bin_path} -C #{repo_path} worktree add --detach #{rebase_path} master))
|
||||
end
|
||||
|
||||
it 'returns false when there is no rebase directory' do
|
||||
FileUtils.rm_rf(rebase_path)
|
||||
it 'returns true when there is a current rebase directory' do
|
||||
expect(subject.rebase_in_progress?).to be_truthy
|
||||
end
|
||||
|
||||
expect(subject.rebase_in_progress?).to be_falsey
|
||||
end
|
||||
it 'returns false when there is no rebase directory' do
|
||||
FileUtils.rm_rf(rebase_path)
|
||||
|
||||
it 'returns false when the rebase directory has expired' do
|
||||
time = 20.minutes.ago.to_time
|
||||
File.utime(time, time, rebase_path)
|
||||
expect(subject.rebase_in_progress?).to be_falsey
|
||||
end
|
||||
|
||||
expect(subject.rebase_in_progress?).to be_falsey
|
||||
end
|
||||
it 'returns false when the rebase directory has expired' do
|
||||
time = 20.minutes.ago.to_time
|
||||
File.utime(time, time, rebase_path)
|
||||
|
||||
it 'returns false when the source project has been removed' do
|
||||
allow(subject).to receive(:source_project).and_return(nil)
|
||||
expect(subject.rebase_in_progress?).to be_falsey
|
||||
end
|
||||
|
||||
expect(subject.rebase_in_progress?).to be_falsey
|
||||
end
|
||||
it 'returns false when the source project has been removed' do
|
||||
allow(subject).to receive(:source_project).and_return(nil)
|
||||
|
||||
expect(subject.rebase_in_progress?).to be_falsey
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -2033,6 +2033,9 @@ describe API::MergeRequests do
|
|||
|
||||
expect(response).to have_gitlab_http_status(202)
|
||||
expect(RebaseWorker.jobs.size).to eq(1)
|
||||
|
||||
expect(merge_request.reload).to be_rebase_in_progress
|
||||
expect(json_response['rebase_in_progress']).to be(true)
|
||||
end
|
||||
|
||||
it 'returns 403 if the user cannot push to the branch' do
|
||||
|
@ -2043,6 +2046,16 @@ describe API::MergeRequests do
|
|||
|
||||
expect(response).to have_gitlab_http_status(403)
|
||||
end
|
||||
|
||||
it 'returns 409 if a rebase is already in progress' do
|
||||
Sidekiq::Testing.fake! do
|
||||
merge_request.rebase_async(user.id)
|
||||
|
||||
put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/rebase", user)
|
||||
end
|
||||
|
||||
expect(response).to have_gitlab_http_status(409)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'Time tracking' do
|
||||
|
|
|
@ -6,10 +6,12 @@ describe MergeRequests::RebaseService do
|
|||
include ProjectForksHelper
|
||||
|
||||
let(:user) { create(:user) }
|
||||
let(:rebase_jid) { 'fake-rebase-jid' }
|
||||
let(:merge_request) do
|
||||
create(:merge_request,
|
||||
create :merge_request,
|
||||
source_branch: 'feature_conflict',
|
||||
target_branch: 'master')
|
||||
target_branch: 'master',
|
||||
rebase_jid: rebase_jid
|
||||
end
|
||||
let(:project) { merge_request.project }
|
||||
let(:repository) { project.repository.raw }
|
||||
|
@ -23,11 +25,11 @@ describe MergeRequests::RebaseService do
|
|||
describe '#execute' do
|
||||
context 'when another rebase is already in progress' do
|
||||
before do
|
||||
allow(merge_request).to receive(:rebase_in_progress?).and_return(true)
|
||||
allow(merge_request).to receive(:gitaly_rebase_in_progress?).and_return(true)
|
||||
end
|
||||
|
||||
it 'saves the error message' do
|
||||
subject.execute(merge_request)
|
||||
service.execute(merge_request)
|
||||
|
||||
expect(merge_request.reload.merge_error).to eq 'Rebase task canceled: Another rebase is already in progress'
|
||||
end
|
||||
|
@ -36,6 +38,13 @@ describe MergeRequests::RebaseService do
|
|||
expect(service.execute(merge_request)).to match(status: :error,
|
||||
message: described_class::REBASE_ERROR)
|
||||
end
|
||||
|
||||
it 'clears rebase_jid' do
|
||||
expect { service.execute(merge_request) }
|
||||
.to change { merge_request.rebase_jid }
|
||||
.from(rebase_jid)
|
||||
.to(nil)
|
||||
end
|
||||
end
|
||||
|
||||
shared_examples 'sequence of failure and success' do
|
||||
|
@ -43,14 +52,19 @@ describe MergeRequests::RebaseService do
|
|||
allow(repository).to receive(:gitaly_operation_client).and_raise('Something went wrong')
|
||||
|
||||
service.execute(merge_request)
|
||||
merge_request.reload
|
||||
|
||||
expect(merge_request.reload.merge_error).to eq described_class::REBASE_ERROR
|
||||
expect(merge_request.reload.merge_error).to eq(described_class::REBASE_ERROR)
|
||||
expect(merge_request.rebase_jid).to eq(nil)
|
||||
|
||||
allow(repository).to receive(:gitaly_operation_client).and_call_original
|
||||
merge_request.update!(rebase_jid: rebase_jid)
|
||||
|
||||
service.execute(merge_request)
|
||||
merge_request.reload
|
||||
|
||||
expect(merge_request.reload.merge_error).to eq nil
|
||||
expect(merge_request.merge_error).to eq(nil)
|
||||
expect(merge_request.rebase_jid).to eq(nil)
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -72,7 +86,7 @@ describe MergeRequests::RebaseService do
|
|||
it 'saves a generic error message' do
|
||||
subject.execute(merge_request)
|
||||
|
||||
expect(merge_request.reload.merge_error).to eq described_class::REBASE_ERROR
|
||||
expect(merge_request.reload.merge_error).to eq(described_class::REBASE_ERROR)
|
||||
end
|
||||
|
||||
it 'returns an error' do
|
||||
|
|
Loading…
Reference in a new issue