Draft: Track pending mergeability checks in Redis instead of the database
Merge request GETs (widget poll, MR page, REST GET) ran an UPDATE merge_requests SET merge_status to mark the MR as checking before enqueueing the mergeability worker. Behind the mergeability_check_redis_marker flag, the GET now claims a short-lived Redis marker and enqueues only if it wins, the worker clears it when done, and the status readers report checking while the marker exists. No database write in the GET, and one UPDATE per check instead of two. The widget-side change (removing the whole-widget checking override so pending merge requests use the merge checks section) is split out to !256153 (merged).
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#showand friends, and the REST API GET.../merge_requests/:iid.- That calls
MergeabilityCheckService#async_execute, which calledmerge_request.mark_as_checking(anUPDATE merge_requests SET merge_status) before enqueueingMergeRequestMergeabilityCheckWorker. The write happened inside the GET request. - Log analysis shows this is roughly 3.6M 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(detect_writes_on_get). Follow-up to #628132, itself a corrective action from INC-12449. - A write during a GET also sticks the user's following requests to the primary via the database load balancer, so this pushed read traffic onto the primary too.
- The write only happened on the first GET after a merge request flipped to
unchecked(nochecking -> checkingtransition in the state machine, so later polls were no-ops). Volume comes from target-branch pushes flipping many MRs touncheckedin batch, each then getting viewed once.
Change
- New
MergeRequests::MergeabilityCheckMarker(app/models/merge_requests/mergeability_check_marker.rb). Backed by a Redis keymerge_request:mergeability_check:<mr_id>inGitlab::Redis::SharedState.claim:SET NX EXwithQUEUED_TTL(5 minutes), returns true only if this call set it.refresh:SET XX EXwithRUNNING_TTL(2 minutes).clear:DEL.pending?:EXISTS.
MergeabilityCheckService#async_execute: flag on, claims the marker and only enqueues the worker if the claim won (no database write). Flag off, unchanged (mark_as_checkingstill runs).MergeRequestMergeabilityCheckWorker#perform: callsrefreshbefore running the check (shrinks the TTL from the queued window to the running window) andclearin anensure, so a finished or failed job frees the merge request for the next check immediately. A job that lost the lease to a check already running (MergeabilityCheckService::FAILED_TO_OBTAIN_LOCKreason) leaves the marker to that check. Both calls are gated on the flag, so with it off the worker is unchanged.AutoMerge::BaseService#available_for?andavailability_detailsmemoize per merge request and percheck_mergeability_asyncmode, so one service instance cannot answer a synchronous caller with a result computed for the widget.- Webhook payloads read the raw
merge_statuscolumn, so the merge request open hook now carriesuncheckedwhere it used to carrychecking;detailed_merge_statusin the same payload reads the marker and still sayschecking.spec/services/merge_requests/after_create_service_spec.rbcovers both flag states. ContentController#widgetran the check synchronously twice per GET:MergeRequestPollWidgetEntity#mergeablecallsMergeRequest#mergeable?, andavailable_auto_merge_strategiesreachesmergeable?throughAutoMerge::MergeWhenChecksPassService#availability_details(and the EE add-to-train variant).mergeable?gains acheck_mergeability_async:option, threaded throughAutoMergeService#available_strategiesandAutoMerge::BaseService#available_for?/availability_details. Only the two read callers set it, gated on the flag: the widget entity andTypes::MergeRequestType#available_auto_merge_strategies.PUT .../merge,MergeOrchestrationServiceand the auto-mergeprocesspaths keep the synchronous check, so strategy selection on a merge action is unchanged. With the flag on and the rowunchecked,mergeableis false and merge-when-checks-pass is listed as available until the worker writes the verdict; the widget shows thecheckingstate fromdetailed_merge_statusin the meantime and re-polls.- Status readers report
checkingwhile a marker is present through one override:MergeRequest#checking?returnssuper || mergeability_check_pending?, soMergeRequest#public_merge_status(REST entities, the widget) andMergeRequests::Mergeability::DetailedMergeStatusService#checking?(GraphQLdetailedMergeStatus, webhooks) pick it up without their own Redis calls.mergeability_check_pending?does the Redis read, but only when the row isuncheckedorcannot_be_merged_recheckand the flag is on, so settled merge requests never touch Redis. While the marker exists,checking?andunchecked?are both true; nothing inapporlibbranches on that combination. - Net effect with the flag on: one UPDATE per check (the verdict) instead of two (checking, then verdict).
Why a Redis marker rather than just moving mark_as_checking into the worker
- Moving the transition into the worker alone would remove the GET write but still costs two UPDATEs per check, and would show
uncheckedinstead ofcheckinguntil the job actually starts. - The marker also dedupes enqueues from the 10-second widget poll while a check is already queued or running.
- A later follow-up can drop the
checkingandcannot_be_merged_recheckingstates from the state machine (and theMergeRequests::MergeDatacopy) entirely.
TTL reasoning and edge cases
- Queued TTL (5 minutes) covers time waiting in Sidekiq, which is exactly what grows during an incident. Running TTL (2 minutes) starts once the worker picks the job up.
- The marker is a liveness hint, not a correctness boundary. Correctness still comes from the existing exclusive lease in the service and the stale-inputs check (
discard_stale_mergeability_verdicts). If the marker expires mid-check, the next poll enqueues a duplicate job that either fails the lease or no-ops because the verdict is already written. The UI showsuncheckedinstead ofcheckingfor one poll; both render the merge checks section with the conflict check spinning once !256153 (merged) lands. - A SIGKILLed worker leaves the marker until TTL expiry, so a lost job can delay a re-check by up to 2 minutes. Today a lost job costs nothing, since the next poll just re-enqueues, so this is a small regression for a rare case. TTL values are a tuning knob and should be checked against the worker duration distribution in Kibana before wider rollout.
- Geo / read-only database:
service_erroralready returns early before the marker is touched. MergeRequests::MergeabilityCheckBatchWorkerstill usesbatch_mark_as_checkingin Sidekiq. Left unchanged, since that write is not happening inside a GET.
Feature flag and rollout
- Flag:
mergeability_check_redis_marker, typegitlab_com_derisk, default disabled, actor is the project. - Rollout issue: #629171 (closed). Plan: enable for
gitlab-org/gitlabfirst, watch thedetect_writes_on_getlogs for the widget endpoint and the worker duration, then roll out by percentage of actors.
Split-out frontend change
- !256153 (merged) removes the whole-widget
checkingstate from the MR widget, soCHECKINGandUNCHECKEDboth fall through to the merge checks section, whose conflict check already shows "Checking for merge conflicts.". Independent of this MR and not flag-gated, so it can merge in either order, but themergeability_check_redis_markerrollout should wait for it; otherwise EE users see "Set to auto-merge" on a mergeable MR while the verdict is pending.
Out of scope, suggested follow-ups
Projects::MergeRequestsController#show_merge_requesttriggers the async check redundantly with the widget poll. Dropping it would take five endpoints off the allowlist.- Remove the merge request endpoints from
ALLOWED_ENDPOINTSinprevent_writes_on_get.rbonce the flag is enabled by default, so specs fail if the write ever returns. - Drop the
checkingstates from the state machine.
Verification
-
Live run on GDK against a real merge request, Sidekiq held to observe the pending state. Flag off: the GET wrote
checking, no marker. Flag on: two GETs left the columnunchecked, set one marker (TTL 299 s, no second enqueue), REST and GraphQL reportedchecking, the widget serializer returnedmergeable: falsewith zeromerge_requestswrites; one worker job then wrotecan_be_mergedand cleared the marker, and GraphQLavailableAutoMergeStrategieswent from["merge_when_checks_pass"]while pending to[]. -
New spec:
spec/models/merge_requests/mergeability_check_marker_spec.rb. -
Widget path specs:
spec/serializers/merge_request_poll_widget_entity_spec.rb(flag on: worker enqueued,mergeablefalse, status unchanged; flag off: synchronous check),spec/models/merge_request_spec.rb(mergeable?async option),spec/services/auto_merge/merge_when_checks_pass_service_spec.rbandee/spec/services/auto_merge/add_to_merge_train_when_checks_pass_service_spec.rb(available_for?withcheck_mergeability_async: truedoes not run the synchronous check). Also green:spec/services/auto_merge/base_service_spec.rb,spec/services/auto_merge_service_spec.rb,ee/spec/services/auto_merge/merge_train_service_spec.rb,spec/graphql/types/merge_request_type_spec.rb. -
Manual GDK check, MR reset to
uncheckedbefore each GET: flag on,cached_widget,show, REST GET/list/changes andwidget.jsonall leavemerge_statusuntouched with 0 merge request writes and the marker set; flag off, the same GETs writechecking(orcan_be_mergedforwidget.json). -
Updated specs:
spec/services/merge_requests/mergeability_check_service_spec.rb(flag on: marker set, nomerge_statuschange, no enqueue when already pending, no marker on read-only DB; flag off: still marks as checking),spec/workers/merge_request_mergeability_check_worker_spec.rb(refresh before run, clear after, clear on raise),spec/models/merge_request_spec.rb(public_merge_statuswith pending marker),spec/services/merge_requests/mergeability/detailed_merge_status_service_spec.rb. -
Also ran green:
spec/serializers/merge_request_poll_cached_widget_entity_spec.rb,spec/serializers/merge_request_poll_widget_entity_spec.rb, the APImerge_statusspec, and the controller "checks mergeability asynchronously" spec. -
Rubocop clean.
Related to #628132