Group CI Lint builds by stage once to avoid redundant calls

What does this MR do and why?

CI Lint turns the YAML processor result into a list of jobs in Gitlab::Ci::Lint#yaml_processor_result_to_jobs. Before this change, it looped over the stages and called result.builds once for each stage, then skipped the jobs from other stages.

Gitlab::Ci::YamlProcessor::Result#builds is not cached. Every call rebuilds the attributes of every job, and each rebuild looks up the job's stage position in the stages list. Large configs with many stages were slow to lint because of this.

This MR calls result.builds once, groups the jobs by stage, and walks the stages in order. The output does not change. Jobs are still ordered by stage, then by the order they are defined in.

Local timings for the job-listing step:

Stages / jobs Before After
100 / 300 0.16 s under 0.01 s
300 / 1,000 1.6 s 0.01 s
1,000 / 3,000 (about 100 KB of YAML) 22.5 s 0.03 s

For the largest case, a full Gitlab::Ci::Lint#validate call went from 26.0 s to 2.8 s. Most of the remaining time is YAML processing.

Scope: This change only affects static lint. That covers the REST lint API, the CI Lint page, the GraphQL lint mutation, and config validation in the pipeline editor. Dry-run lint uses a different method, so it does not change. Pipeline creation does not change either. It uses Result#stages_attributes, which is already cached.

A new shared example in spec/lib/gitlab/ci/lint_spec.rb covers #legacy_validate and static mode of #validate. It checks the job order and checks that builds is called only once.

References

How to set up and validate locally

  1. Open a Rails console: bin/rails console.

  2. Build a config with many stages and jobs, then time the lint call:

    project = Project.first
    user = project.first_owner
    
    stages = 1000; jobs = 3000
    yaml = +"stages: [#{(1..stages).map { |i| "s#{i}" }.join(', ')}]\n"
    jobs.times { |n| yaml << "j#{n}:\n  stage: s#{(n % stages) + 1}\n  script: x\n" }
    
    lint = Gitlab::Ci::Lint.new(project: project, current_user: user)
    result = nil
    Benchmark.realtime { result = lint.validate(yaml, dry_run: false, ref: project.default_branch) }
  3. Check that result.valid? is true and result.jobs.size is 3000. The call should take a few seconds (about 3 s in a GDK).

  4. Optional: revert the change in lib/gitlab/ci/lint.rb, run reload!, and repeat step 2. The call should take about 25 s. Compare result.jobs from both runs. They should be the same.

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 Manuel Grabowski

Merge request reports

Loading
Loading