Fix merge-train pipeline canceled on skip_merge_train race
What does this MR do and why?
Fixes a race condition (Zendesk ticket 741014) where an active merge-train
pipeline is incorrectly canceled when an off-train MR is merged with
skip_merge_train=true.
Root cause
When MergeService merges an off-train MR with skip_merge_train=true, the
call order was:
execute_git_merge!-> git push to target branch -> Gitaly post-receive hook ->PostReceiveWorkerenqueued (async)merge_request.update!(merged_commit_sha: ...)-- SHA persisted to DBEE::MergeRequests::MergeService#after_merge->MergeTrains::Car.insert_skip_merged_car_for-- skip_merged car created
UpdateMergeRequestsWorker (urgency: high) is enqueued in step 1 and can
execute before step 3 completes. When it runs, it calls
MergeRequests::RefreshService -> EE::MergeRequests::RefreshService#check_merge_train_status
-> MergeTrains::CheckStatusService#execute ->
MergeTrains::Train#sha_exists_in_history?(newrev).
Until step 3 completes there is no completed car for the off-train MR, so
sha_exists_in_history? returns false, causing
first_active_car.outdate_pipeline to cancel and recreate the running
merge-train pipeline.
Fix
Move insert_skip_merged_car_for before the git push.
EE::MergeRequests::MergeService#commit now calls insert_skip_merged_car_for
BEFORE calling super (which does the git merge and push). The MR is already
locked at this point (inside in_locked_state), satisfying the guard in
insert_skip_merged_car_for.
No query changes are needed: completed_cars already includes skip_merged
cars, and the existing SHA condition in sha_exists_in_history? matches
in_progress_merge_commit_sha, which is set during the Gitaly merge (before
the push) and only cleared after merged_commit_sha is persisted. With the
car created before the push, the existing check returns true throughout the
race window. ee/app/models/merge_trains/train.rb is unchanged by this MR.
Rollback on merge failure.
If the git merge raises (MergeRequests::MergeBaseService::MergeError, or
MergeRequests::MergeStrategies::StrategyError, which try_merge re-raises
unchanged to preserve its user-facing message), the skip_merged car is
destroyed before re-raising, so a failed merge leaves no stale car in
completed history. Both error classes are raised only before anything lands
on the target branch. Post-push failures (for example a transient DB error
while persisting merged_commit_sha) intentionally keep the car, because the
commit is already on the branch and the car legitimately belongs in train
history. Destroying a pipeline-less skip_merged car is safe:
Ci::CancelPipelineService#force_execute returns early for a nil pipeline.
Remaining limitation
The sub-millisecond window before the MR is locked (before
in_progress_merge_commit_sha is set by the Gitaly merge) is not covered.
This window predates the git push, so UpdateMergeRequestsWorker cannot have
been enqueued yet. The dangerous window (between git push and
merged_commit_sha persistence) is fully covered.
References
- Zendesk ticket: 741014
- Issue: https://gitlab.com/gitlab-org/gitlab/-/issues/741014
Screenshots or screen recordings
N/A - backend race condition fix.
How to set up and validate locally
- Create a project with merge pipelines enabled, merge trains enabled,
merge_trains_skip_train_allowed: true, merge method = merge commits. - Create MR A targeting default branch; add to merge train; ensure pipeline is running (sleep in CI).
- Create MR B targeting same branch (not on train).
- Merge MR B via API with
skip_merge_train=true. - Observe MR A's pipeline continues running (was: canceled and recreated).
Test coverage
Regression specs in ee/spec/models/merge_trains/train_spec.rb,
ee/spec/services/merge_trains/check_status_service_spec.rb,
ee/spec/services/ee/merge_requests/merge_service_spec.rb, and
ee/spec/models/merge_trains/car_spec.rb:
- Race window: skip_merged car exists,
in_progress_merge_commit_sha== newrev, SHA not yet on MR ->sha_exists_in_history?returns true (train not invalidated). - Completed skip-merge: car exists, SHA on MR, MR merged -> returns true.
- Normal off-train merge (no skip_merged car): returns false (train invalidation preserved).
- Unexpected commit (newrev differs from
in_progress_merge_commit_sha): returns false (train invalidated). - Different target branch/project: skip_merged car elsewhere does not suppress invalidation for this branch's train.
- Rollback: if the git merge raises after the skip_merged car is created
(
MergeError, or aStrategyErrorthattry_mergere-raises unchanged), the car is destroyed (no stale car remains); destroying a pipeline-less skip_merged car does not raise.
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.
Database review
MergeTrains::Car.insert_skip_merged_car_for (now called before the git push)
has_one load performed by create_merge_train_car:
SELECT "merge_trains".* FROM "merge_trains" WHERE "merge_trains"."merge_request_id" = 517595598 LIMIT 1- Plan: https://console.postgres.ai/gitlab/gitlab-production-main/sessions/56863/commands/161324
- Index Scan using
index_merge_trains_on_merge_request_id, 3 buffers, ~5 ms (cold cache)
INSERT of the skip_merged car:
INSERT INTO "merge_trains" ("merge_request_id", "user_id", "created_at", "updated_at", "target_project_id", "target_branch", "status", "merged_at")
VALUES (517595598, 8410247, now(), now(), 278964, 'master', 5, now())
RETURNING "id"- Plan: https://console.postgres.ai/gitlab/gitlab-production-main/sessions/56863/commands/161325
- ~19 ms, 191 buffers (cold cache); cost is the three FK checks (
projects,users,merge_requests)
skip_merged_car&.destroy (new rollback path when the git merge raises MergeError or StrategyError)
DELETE FROM "merge_trains" WHERE "merge_trains"."id" = 10181056- Plan: https://console.postgres.ai/gitlab/gitlab-production-main/sessions/56863/commands/161330
- Index Scan using
merge_trains_pkey, 1 row, 9 buffers, ~3 ms (cold cache)
after_destroy → try_cleanup_ref(async: false) → MergeRequests::GeneratedRefCommit.where(project:, merge_request:).delete_all:
DELETE FROM "p_generated_ref_commits"
WHERE "p_generated_ref_commits"."project_id" = 278964
AND "p_generated_ref_commits"."merge_request_iid" = 249384
AND "p_generated_ref_commits"."project_id" = 278964- Plan: https://console.postgres.ai/gitlab/gitlab-production-main/sessions/56863/commands/161326
- Partition pruned to
p_generated_ref_commits_1, Index Scan onproject_id, 0 rows, 2 buffers, ~4 ms (cold cache) - 0 rows is the expected production case: a
skip_mergedcar was never on a train, so it has no generated ref commits - The
Ci::CancelPipelineServicecall in the same callback issues no query (pipeline_idisnil, service returns early)
MergeTrains::Train#sha_exists_in_history? (unchanged; the fix relies on its timing)
SELECT 1 AS one
FROM "merge_requests"
WHERE "merge_requests"."id" IN (
SELECT "merge_trains"."merge_request_id"
FROM "merge_trains"
WHERE "merge_trains"."target_project_id" = 278964
AND "merge_trains"."target_branch" = 'master'
AND "merge_trains"."status" IN (1, 4, 5)
ORDER BY "merge_trains"."id" DESC
LIMIT 20
)
AND (
merge_commit_sha = '36c7684e65432abcacc13eac860f62db0a73c2c0'
OR in_progress_merge_commit_sha = '36c7684e65432abcacc13eac860f62db0a73c2c0'
OR squash_commit_sha = '36c7684e65432abcacc13eac860f62db0a73c2c0'
OR merged_commit_sha = '36c7684e65432abcacc13eac860f62db0a73c2c0'
)
LIMIT 1- Cold cache: https://console.postgres.ai/gitlab/gitlab-production-main/sessions/56863/commands/161323 (~451 ms, 470 reads)
- Warm cache: https://console.postgres.ai/gitlab/gitlab-production-main/sessions/56863/commands/161329 (~1.3 ms, 1819 hits)
- Nested Loop:
merge_trains_pkeybackward scan +LIMIT 20, then 20merge_requests_pkeylookups