Limit concurrent project file exports
What does this MR do and why?
CreateRelationExportsWorker fans out ~35 RelationExportWorker jobs per export with no
throttling. When many project exports run at the same time, Sidekiq nodes can become saturated
with relation export jobs, and ParallelProjectExportWorker — which later reassembles all
relations into a single archive on one node — creates additional memory and disk pressure.
This MR introduces a global concurrency limit for whole-project exports, controlled by the new
concurrent_relation_export_limit application setting (default: 25). When the limit is reached,
CreateRelationExportsWorker re-enqueues itself instead of fanning out relation export jobs,
and the export stays in the queued state until a slot opens up.
Key design decisions:
- FIFO fairness — only the oldest
available_capacityqueued jobs may proceed, so freed slots go to the longest-waiting export rather than whichever re-enqueue timer fires first. - JID tracking —
perform_inmints a new Sidekiq JID on every re-enqueue. The new JID is written back to theProjectExportJobrow soStuckExportJobsWorkerdoes not mistake a throttled-but-alive export for a completed one. - Timed-out job exclusion — queued jobs not updated within
QUEUED_JOBS_EXPIRATION(6 ×RE_ENQUEUE_DELAY) are excluded from the FIFO pool; started jobs pastStuckExportJobsWorker::EXPORT_JOBS_EXPIRATIONare excluded from the concurrency count. Both prevent abandoned rows from permanently occupying slots. - Deduplication — changed from
:until_executedto:until_executed, if_deduplicated: :reschedule_onceso a self re-enqueue that arrives while the scheduling execution still holds the lock is rescheduled rather than silently dropped. WaitRelationExportsWorkertolerance — updated to handle JID changes that occur mid-check, since a throttled export's JID is no longer stable.
The feature is behind the limit_concurrent_project_exports feature flag (off by default,
type: gitlab_com_derisk). When the flag is disabled, the existing JID-based lookup path is
preserved unchanged.
References
https://gitlab.com/gitlab-org/gitlab/-/work_items/599092
Query plans
1 - ProjectExportJob.find_queued_or_started(project, user_id)
https://console.postgres.ai/gitlab/projects/gitlab-production-main/sessions/54824/commands/157894
SELECT "project_export_jobs".* FROM "project_export_jobs"
WHERE "project_export_jobs"."user_id" = $1
AND "project_export_jobs"."status" IN (0, 1)
AND "project_export_jobs"."project_id" = $2
LIMIT 1Query plan
Limit (cost=0.42..5.38 rows=1 width=68) (actual time=0.048..0.048 rows=0 loops=1)
Buffers: shared hit=12
I/O Timings: read=0.000 write=0.000
-> Index Scan using index_project_export_jobs_on_project_id_and_status on public.project_export_jobs (cost=0.42..5.38 rows=1 width=68) (actual time=0.046..0.046 rows=0 loops=1)
Index Cond: ((project_export_jobs.project_id = 52663939) AND (project_export_jobs.status = ANY ('{0,1}'::integer[])))
Filter: (project_export_jobs.user_id = 37517615)
Buffers: shared hit=12
I/O Timings: read=0.000 write=0.000
Settings: jit = 'off', random_page_cost = '1.5', work_mem = '230MB', seq_page_cost = '4', effective_cache_size = '472585MB'
Query ID: 609099436563933868Time: 1.609 ms
- planning: 1.492 ms
- execution: 0.117 ms
- I/O read: 0.000 ms
- I/O write: 0.000 ms
Shared buffers:
- hits: 12 (~96.00 KiB) from the buffer pool
- reads: 0 from the OS file cache, including disk I/O
- dirtied: 0
- writes: 02 - started_and_not_timed_out.limit(limit).count - used in next_in_queue?
https://console.postgres.ai/gitlab/projects/gitlab-production-main/sessions/54824/commands/157891
SELECT Count(*)
FROM
(SELECT 1 AS one
FROM "project_export_jobs"
WHERE "project_export_jobs"."status" = 1
AND "project_export_jobs"."updated_at" >= '2026-08-14 12:16:30.621921'
LIMIT 25) subquery_for_countQuery plan
Aggregate (cost=3.42..3.43 rows=1 width=8) (actual time=0.011..0.012 rows=1 loops=1)
Buffers: shared hit=3
I/O Timings: read=0.000 write=0.000
-> Limit (cost=0.42..3.41 rows=1 width=4) (actual time=0.008..0.008 rows=0 loops=1)
Buffers: shared hit=3
I/O Timings: read=0.000 write=0.000
-> Index Scan using index_project_export_jobs_on_updated_at_and_id on public.project_export_jobs (cost=0.42..3.41 rows=1 width=4) (actual time=0.006..0.007 rows=0 loops=1)
Index Cond: (project_export_jobs.updated_at >= '2026-08-14 12:16:30.621921+00'::timestamp with time zone)
Filter: (project_export_jobs.status = 1)
Buffers: shared hit=3
I/O Timings: read=0.000 write=0.000
Settings: jit = 'off', random_page_cost = '1.5', work_mem = '230MB', seq_page_cost = '4', effective_cache_size = '472585MB'
Query ID: 2997175635489819987Time: 1.016 ms
- planning: 0.933 ms
- execution: 0.083 ms
- I/O read: 0.000 ms
- I/O write: 0.000 ms
Shared buffers:
- hits: 3 (~24.00 KiB) from the buffer pool
- reads: 0 from the OS file cache, including disk I/O
- dirtied: 0
- writes: 03 - queued_and_not_timed_out(timeout).order(:id).limit(available_capacity).pluck(:id) - used in next_in_queue?
https://console.postgres.ai/gitlab/projects/gitlab-production-main/sessions/54824/commands/157895
SELECT "project_export_jobs"."id" FROM "project_export_jobs"
WHERE "project_export_jobs"."status" = 0
AND "project_export_jobs"."updated_at" >= $1
ORDER BY "project_export_jobs"."id" ASC
LIMIT 25Query plan
Limit (cost=3.42..3.42 rows=1 width=8) (actual time=0.043..0.044 rows=0 loops=1)
Buffers: shared hit=6
I/O Timings: read=0.000 write=0.000
-> Sort (cost=3.42..3.42 rows=1 width=8) (actual time=0.042..0.042 rows=0 loops=1)
Sort Key: project_export_jobs.id
Sort Method: quicksort Memory: 25kB
Buffers: shared hit=6
I/O Timings: read=0.000 write=0.000
-> Index Scan using index_project_export_jobs_on_updated_at_and_id on public.project_export_jobs (cost=0.42..3.41 rows=1 width=8) (actual time=0.005..0.005 rows=0 loops=1)
Index Cond: (project_export_jobs.updated_at >= '2026-08-14 10:00:00+00'::timestamp with time zone)
Filter: (project_export_jobs.status = 0)
Buffers: shared hit=3
I/O Timings: read=0.000 write=0.000
Settings: seq_page_cost = '4', effective_cache_size = '472585MB', jit = 'off', random_page_cost = '1.5', work_mem = '230MB'
Query ID: 7429594295680629517Time: 1.314 ms
- planning: 1.230 ms
- execution: 0.084 ms
- I/O read: 0.000 ms
- I/O write: 0.000 ms
Shared buffers:
- hits: 6 (~48.00 KiB) from the buffer pool
- reads: 0 from the OS file cache, including disk I/O
- dirtied: 0
- writes: 0Screenshots or screen recordings
| Before | After |
|---|---|
How to set up and validate locally
concurrent_relation_export_limit gates a global count of started ProjectExportJobs, not a
per-user or per-namespace one, so a handful of projects owned by a single user is enough to see it
kick in. The feature is behind limit_concurrent_project_exports, which is off by default, so
that has to be enabled first or the limit is never checked.
- Enable the feature flag:
Feature.enable(:limit_concurrent_project_exports) - Lower the limit so it's easy to exceed, e.g. to
2:ApplicationSetting.current.update!(concurrent_relation_export_limit: 2) - Make each relation export slow enough to observe a project sitting in
started, by adding asleep 30at the top ofProjects::ImportExport::RelationExportWorker#perform. - Pick at least one more project than the limit (e.g. 3), all owned/maintained by the same user,
and export them all at roughly the same time:
for id in <id1> <id2> <id3>; do curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" \ "http://gdk.test:3000/api/v4/projects/$id/export" & done wait - While the
sleeps are still running, poll each project's status:Onlyfor id in <id1> <id2> <id3>; do curl --header "PRIVATE-TOKEN: <your_access_token>" \ "http://gdk.test:3000/api/v4/projects/$id/export" | jq '{id, export_status}' doneconcurrent_relation_export_limitof them (2, in this example) should reportexport_status: started— the rest reportqueued, and pick up a freed slot (oldest queued first) as thestartedones finish.ProjectExportJob.pluck(:project_id, :status)in Rails console shows the same thing without polling the API. - To confirm the flag actually gates the behavior, repeat with
Feature.disable(:limit_concurrent_project_exports)— all projects should move tostartedimmediately regardless of the limit.
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.