Loading
Move the checking merge status write into the mergeability worker
Moves the merge_status = checking UPDATE out of the GET request path (widget poll, MR page, REST GET) and into MergeRequestMergeabilityCheckWorker, behind the mark_mergeability_checking_in_worker flag. The GET now only enqueues the worker, and the worker marks the row as checking right before running the check, so the row reads unchecked until the job is picked up. This is the simpler alternative to the Redis marker in !255070 (closed) - only one of the two should merge.
Detailed context for AI agents
Problem
MergeRequest#check_mergeability(async: true)is called from GET paths: the widget poll (Projects::MergeRequests::ContentController#cached_widget),Projects::MergeRequestsController#show, and the REST GETGET /projects/:id/merge_requests/:merge_request_iid.- It calls
MergeRequests::MergeabilityCheckService#async_execute, which ranmerge_request.mark_as_checking(anUPDATE merge_requests SET merge_status = 'checking') before enqueueingMergeRequestMergeabilityCheckWorker. That is a database write inside a GET request. - Log analysis in the linked work item shows roughly 3.6M such writes per week, making the widget the top GET endpoint writing to the database. These endpoints are currently allowlisted in
lib/gitlab/database/query_analyzers/prevent_writes_on_get.rb. The work item is a corrective action from incident INC-12449. - A write during a GET also sticks the user's following requests to the primary database via the load balancer.
Change
Small: 2 production files, 2 spec files, 1 feature flag YAML.
- New feature flag
mark_mergeability_checking_in_worker, typegitlab_com_derisk, default disabled, actor is the project. MergeabilityCheckService#async_execute: with the flag on, skipsmark_as_checkingand only enqueues the worker. Flag off: unchanged.MergeRequestMergeabilityCheckWorker#perform: with the flag on, calls the guardedMergeRequest.batch_mark_as_checking([merge_request.id])right before runningMergeabilityCheckService#execute. It only moves rows still inunchecked/cannot_be_merged_recheck, so a verdict written betweenfind_by_idand this line is left alone (raised in review by Patrick). Flag off: unchanged.- Net effect: same number of UPDATEs per check as today (checking, then the verdict), but the first one moves from the web request into Sidekiq.
Behaviour with the flag on
- Between the GET and the job being picked up, the row reads
uncheckedinstead ofchecking.public_merge_status, GraphQLdetailedMergeStatus(UNCHECKEDrather thanCHECKING) and webhooks reflect that. - No visible widget change. The whole-widget "checking" spinner was removed in !256153 (merged), and
CheckConflictStatusServicereportscheckingfor bothuncheckedandchecking, so the widget shows "Checking if merge request can be merged..." with the conflict check spinning in both states. Verified on GDK with the flag off (rowchecking) and on (rowunchecked): pixel-identical widgets. Note that the MR page itself usually settles the status synchronously throughwidget.json(MergeRequestPollWidgetEntity#mergeable), so theuncheckedwindow is mostly visible to the REST GET andcached_widgetcallers. GET /projects/:id/merge_requests?with_merge_status_recheck=truealready works this way: the GET only enqueuesMergeabilityCheckBatchWorker, which runsbatch_mark_as_checkingin Sidekiq.- Enqueue dedup: the worker is
idempotent!, so Sidekiq already deduplicates with the defaultuntil_executingstrategy while a job is queued. Re-enqueues during a running job are not deduplicated, same as today, becauserecheck_merge_status?is true forcheckingtoo. - The guarded
update_allmatches nothing when the row is alreadychecking,preparing, or settled, so a job enqueued before the flag flipped, or one racing a synchronous check, is a no-op on that line. - Jobs enqueued while the flag was off already had
checkingwritten in the GET; the worker's guarded write is a no-op for them.
Alternative approach
- !255070 (closed) (same author) implements a Redis marker instead: the GET claims a short-lived Redis key and enqueues only if it wins, the worker clears it, and status readers report
checkingwhile the key exists. That saves one UPDATE per check and dedupes during the running window, but adds a Redis round trip to status reads and a new lifecycle to reason about. - This MR is the simpler alternative raised in that MR's review thread: move the write into the worker and accept that the status reads
uncheckeduntil the job starts. The two MRs are alternatives; only one should merge.
Edge cases
- Race: the worker writes
checkingoutside the mergeability exclusive lease. An earlier revision usedmerge_request.mark_as_checking, which saves the in-memory row, so a verdict a synchronous check (for examplePUT .../merge) wrote betweenfind_by_idand that line was flipped back tochecking; if the worker then lost the lease the row stayedcheckinguntil the next poll. The guardedbatch_mark_as_checkingcloses that: the in-memory row still readsunchecked, which is a valid source state for every later transition, andwrite_merge_statusalready discards stale verdicts. Covered by the worker spec exampledoes not overwrite a merge status written after the merge request was loaded, which fails on the unguarded line. MergeRequests::MergeabilityCheckBatchWorkerstill usesbatch_mark_as_checking; unchanged, because that write already happens in Sidekiq.- Geo / read-only database:
service_errorinasync_executestill returns early before enqueueing.
Feature flag and rollout
- Flag
mark_mergeability_checking_in_worker. Rollout issue: #630354. - Plan: enable for
gitlab-org/gitlabfirst, watch thedetect_writes_on_getlogs for the widget endpoint and the worker's duration, then percentage of actors. - Once default-on, follow-ups: remove the allowlist entries in
prevent_writes_on_get.rb, and considerdeduplicate :until_executed, if_deduplicated: :reschedule_onceon the worker.
Verification
spec/services/merge_requests/mergeability_check_service_spec.rb#async_executeblock: flag on leaves the rowunchecked, flag off markschecking; 4 examples pass.spec/workers/merge_request_mergeability_check_worker_spec.rb: flag on markscheckingbeforeexecute, flag off leavesunchecked, and a verdict written afterfind_by_idis not overwritten (fails on the unguarded line); 9 examples pass including the idempotent worker shared example.spec/models/merge_request_spec.rb#check_mergeabilityblock: 11 examples pass.- RuboCop clean on all changed files.
Try-to-break testing (GDK, 2026-09-22)
All with mark_mergeability_checking_in_worker enabled for the project unless stated.
- Dedup: three REST GETs on an
uncheckedMR with Sidekiq stopped produced one queued job. Theuntil_executingcookie lives 10 minutes, so even the later page load was absorbed. - No GET writes: single GET, project list GET with
with_merge_status_recheck=true, and the widget page all left the rowuncheckedand only enqueued jobs. - Flag flipped off between enqueue and run: the worker wrote the verdict straight from
unchecked. Flag flipped on between a flag-off enqueue (row alreadychecking) and run: the worker'smark_as_checkingwas a no-op and the verdict was written. - Worker loses the lease to a synchronous check: the row is left at
checkingand the job logsFailed to obtain a lock. The next poll re-enqueues (the dedup cookie is cleared when a job starts) and settles it once the lease is free. Same behaviour as today. - Stale overwrite: reproduced in a Rails runner against the unguarded
mark_as_checkingrevision (verdict flipped back tochecking, row stuck until the next poll). Now closed by the guardedbatch_mark_as_checking; see the race edge case above. - Rows in
preparing, alreadychecking, or settled are not matched by the guarded update, and the followingmark_as_mergeablestill runs from the in-memory state. - GraphQL while queued:
mergeStatusEnumanddetailedMergeStatusareUNCHECKEDinstead ofCHECKING; theCONFLICTmergeability check isCHECKINGeither way, so the widget renders the same spinner. - Webhooks: the merge request
openhook payload now carriesmerge_status: uncheckedwhere it carriedchecking.spec/services/merge_requests/after_create_service_spec.rbcovers both flag states. The predictive rspec pipeline did not select that spec, so this only surfaced locally. - Local run of 1217 examples across the mergeability service, worker, batch worker, entities, controller, REST API and
prevent_writes_on_getspecs: green after the webhook spec update.
Out of scope
- Removing the
checkingstate from the state machine. - The redundant check trigger in
Projects::MergeRequestsController#show(the widget poll triggers the same check right after page load). - The allowlist entries in
prevent_writes_on_get.rb.
References
Edited by Marc Shaw