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
-
Open a Rails console:
bin/rails console. -
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) } -
Check that
result.valid?istrueandresult.jobs.sizeis3000. The call should take a few seconds (about 3 s in a GDK). -
Optional: revert the change in
lib/gitlab/ci/lint.rb, runreload!, and repeat step 2. The call should take about 25 s. Compareresult.jobsfrom 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.