Add background operation to clear old merged MR cached HTML
What does this MR do and why?
Add background operation to clear old merged MR cached HTML
Merge requests merged years ago keep their rendered Markdown in title_html and description_html indefinitely, even when the cache is at the current version. That HTML is very unlikely to be read again, and the cache is self-healing: the next read re-renders both fields and writes back the current version. Clearing it reclaims the bytes and costs at most one render if someone does come back.
Eligibility is merged more than three years ago, and a cached_markdown_version at or above the bound returned by cached_markdown_version_for_bulk_clear. That version condition is the mirror image of the one in MergeRequestsClearStaleCachedHtml, so the two operations partition the table rather than overlap: that one owns stale caches, this one owns current caches on old merge requests. It also makes repeated runs converge, because clearing sets the version to NULL and NULL >= target is NULL.
merged_at is not a column on merge_requests, so the operation joins merge_request_metrics. The join is 1:1 and index backed. Merge requests with no metrics row, or with a NULL merged_at, are skipped; being conservative costs nothing here.
There is no scope_to. It would apply the filter to the batch boundary queries too, and neither filter is indexed on merge_requests, so every boundary query would become an unindexed scan of a very large table. Iterating plainly on id keeps boundaries on the primary key. Because the relation each_sub_batch yields is bounded by a LIMIT rather than an upper id, the filters are applied after binding the window with where(id: sub_batch.select(:id)), so a sub-batch cannot reach past its own window.
See https://gitlab.com/gitlab-org/gitlab/-/issues/608121
References
Screenshots or screen recordings
| Before | After |
|---|---|
DB Query
Get below SQL from RSpec, and update some numbers for id in query plan.
D, [2026-08-26T16:38:06.619194 #41491] DEBUG -- : MergeRequest Minimum (0.2ms) SELECT MIN("merge_requests"."id") FROM "merge_requests" /*application:test,correlation_id:9b0bd244f715ff33c7d11363927f835e,db_config_database:gitlabhq_test,db_config_name:main,line:/spec/lib/gitlab/background_operation/merge_requests_clear_old_merged_cached_html_spec.rb:39:in `operation'*/
D, [2026-08-26T16:38:06.619438 #41491] DEBUG -- : ↳ spec/lib/gitlab/background_operation/merge_requests_clear_old_merged_cached_html_spec.rb:39:in `operation'
D, [2026-08-26T16:38:06.620576 #41491] DEBUG -- : MergeRequest Maximum (0.2ms) SELECT MAX("merge_requests"."id") FROM "merge_requests" /*application:test,correlation_id:9b0bd244f715ff33c7d11363927f835e,db_config_database:gitlabhq_test,db_config_name:main,line:/spec/lib/gitlab/background_operation/merge_requests_clear_old_merged_cached_html_spec.rb:40:in `operation'*/
D, [2026-08-26T16:38:06.620804 #41491] DEBUG -- : ↳ spec/lib/gitlab/background_operation/merge_requests_clear_old_merged_cached_html_spec.rb:40:in `operation'
D, [2026-08-26T16:38:06.625166 #41491] DEBUG -- : Load (0.4ms) SELECT "merge_requests".* FROM "merge_requests" INNER JOIN merge_request_metrics ON merge_request_metrics.merge_request_id = merge_requests.id WHERE ("merge_requests"."id") <= (130) AND (merge_request_metrics.merged_at < '2023-08-26 06:38:06.623553') AND ("merge_requests"."id") >= (127) ORDER BY "merge_requests"."id" ASC LIMIT 2 OFFSET 1 /*application:test,correlation_id:9b0bd244f715ff33c7d11363927f835e,db_config_database:gitlabhq_test,db_config_name:main,line:/lib/gitlab/database/dynamic_model_helpers.rb:21:in `block in define_batchable_model'*/
D, [2026-08-26T16:38:06.625410 #41491] DEBUG -- : ↳ lib/gitlab/database/dynamic_model_helpers.rb:21:in `block in define_batchable_model'
D, [2026-08-26T16:38:06.636645 #41491] DEBUG -- : #<Class:0x0000000158068980> Update All (0.6ms) UPDATE "merge_requests" SET "title_html" = NULL, "description_html" = NULL, "cached_markdown_version" = NULL, "lock_version" = COALESCE("lock_version", 0) + 1 WHERE "merge_requests"."id" IN (SELECT "merge_requests"."id" FROM "merge_requests" INNER JOIN merge_request_metrics ON merge_request_metrics.merge_request_id = merge_requests.id WHERE ("merge_requests"."id") <= (130) AND (merge_request_metrics.merged_at < '2023-08-26 06:38:06.623553') AND ("merge_requests"."id") >= (127) ORDER BY "merge_requests"."id" ASC LIMIT 2) AND (cached_markdown_version >= 200) /*application:test,correlation_id:9b0bd244f715ff33c7d11363927f835e,db_config_database:gitlabhq_test,db_config_name:main,line:/lib/gitlab/database/dynamic_model_helpers.rb:21:in `block in define_batchable_model'*/https://console.postgres.ai/gitlab/projects/gitlab-production-main/sessions/55383/commands/159042
https://console.postgres.ai/gitlab/projects/gitlab-production-main/sessions/55383/commands/159047
How to set up and validate locally
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.