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_capacity queued jobs may proceed, so freed slots go to the longest-waiting export rather than whichever re-enqueue timer fires first.
  • JID trackingperform_in mints a new Sidekiq JID on every re-enqueue. The new JID is written back to the ProjectExportJob row so StuckExportJobsWorker does 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 past StuckExportJobsWorker::EXPORT_JOBS_EXPIRATION are excluded from the concurrency count. Both prevent abandoned rows from permanently occupying slots.
  • Deduplication — changed from :until_executed to :until_executed, if_deduplicated: :reschedule_once so a self re-enqueue that arrives while the scheduling execution still holds the lock is rescheduled rather than silently dropped.
  • WaitRelationExportsWorker tolerance — 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 1
Query 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: 609099436563933868
Time: 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: 0

2 - 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_count
Query 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: 2997175635489819987
Time: 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: 0

3 - 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 25
Query 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: 7429594295680629517
Time: 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: 0

Screenshots 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.

  1. Enable the feature flag:
    Feature.enable(:limit_concurrent_project_exports)
  2. Lower the limit so it's easy to exceed, e.g. to 2:
    ApplicationSetting.current.update!(concurrent_relation_export_limit: 2)
  3. Make each relation export slow enough to observe a project sitting in started, by adding a sleep 30 at the top of Projects::ImportExport::RelationExportWorker#perform.
  4. 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
  5. While the sleeps are still running, poll each project's status:
    for 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}'
    done
    Only concurrent_relation_export_limit of them (2, in this example) should report export_status: started — the rest report queued, and pick up a freed slot (oldest queued first) as the started ones finish. ProjectExportJob.pluck(:project_id, :status) in Rails console shows the same thing without polling the API.
  6. To confirm the flag actually gates the behavior, repeat with Feature.disable(:limit_concurrent_project_exports) — all projects should move to started immediately 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.

Edited by Rodrigo Tomonari

Merge request reports

Loading
Loading