Race condition in commit status API creates multiple pipelines
Summary
Simultaneous requests to POST /projects/:id/statuses/:sha
(https://docs.gitlab.com/ee/api/commits.html#post-the-build-status-to-a-commit) result in multiple pipelines being created.
Steps to reproduce
- Create a project, without .gitlab.ci.yml
- Push a new commit
- Post two distinct status updates to that commit at the same time e.g.
Request 1:
POST /projects/:id/statuses/:sha
{
"name": "security",
"status": "running",
"description": "testing"
}
Request 2:
POST /projects/:id/statuses/:sha
{
"name": "licenses",
"status": "running",
"description": "testing"
}
You should intermittently see the creation of two separate pipelines, rather than one pipeline.
We initially observed this behaviour during integration with Snyk. Snyk's integration with Gitlab posts two build statuses to Gitlab ("licenses" and "security").
Because the duplicate pipeline is created, subsequent updates (e.g. posting a success status) only apply to the latest pipeline, which leaves jobs in the second pipeline appearing to "run" forever.
What is the current bug behavior?
Simultaneous updates of commit status intermittently result in duplicate pipelines being created
What is the expected correct behavior?
Simultaneous updates of commit status should result in a single pipeline being created.
Output of checks
Results of GitLab environment info
Expand for output related to GitLab environment info
sudo gitlab-rake gitlab:env:info
System information System: Ubuntu 16.04 Proxy: no
Current User: git Using RVM: no Ruby Version: 2.6.3p62 Gem Version: 2.7.9 Bundler Version:1.17.3 Rake Version: 12.3.2 Redis Version: 3.2.12 Git Version: 2.21.0 Sidekiq Version:5.2.7 Go Version: unknown
GitLab information Version: 12.0.3-ee Revision: 1b1872f9d93 Directory: /opt/gitlab/embedded/service/gitlab-rails DB Adapter: PostgreSQL DB Version: 9.6.8 URL: HTTP Clone URL: SSH Clone URL: Elasticsearch: no Geo: no Using LDAP: no Using Omniauth: yes Omniauth Providers: saml
GitLab Shell Version: 9.3.0 Repository storage paths:
- default: /data/git/repositories GitLab Shell path: /opt/gitlab/embedded/service/gitlab-shell Git: /opt/gitlab/embedded/bin/git
Results of GitLab application Check
Expand for output related to the GitLab application check
Checking GitLab subtasks ...Checking GitLab Shell ...
GitLab Shell: ... GitLab Shell version >= 9.3.0 ? ... OK (9.3.0) Running /opt/gitlab/embedded/service/gitlab-shell/bin/check Check GitLab API access: OK Redis available via internal API: OK
Access to /var/opt/gitlab/.ssh/authorized_keys: OK gitlab-shell self-check successful
Checking GitLab Shell ... Finished
Checking Gitaly ...
Gitaly: ... default ... OK
Checking Gitaly ... Finished
Checking Sidekiq ...
Sidekiq: ... Running? ... yes Number of Sidekiq processes ... 1
Checking Sidekiq ... Finished
Checking Incoming Email ...
Incoming Email: ... Reply by email is disabled in config/gitlab.yml
Checking Incoming Email ... Finished
Checking LDAP ...
LDAP: ... LDAP is disabled in config/gitlab.yml
Checking LDAP ... Finished
Checking GitLab App ...
Git configured correctly? ... yes Database config exists? ... yes All migrations up? ... yes Database contains orphaned GroupMembers? ... no GitLab config exists? ... yes GitLab config up to date? ... yes Log directory writable? ... yes Tmp directory writable? ... yes Uploads directory exists? ... yes Uploads directory has correct permissions? ... yes Uploads directory tmp has correct permissions? ... yes Init script exists? ... skipped (omnibus-gitlab has no init script) Init script up-to-date? ... skipped (omnibus-gitlab has no init script) Projects have namespace: ...
......(all green)..
Redis version >= 2.8.0? ... yes Ruby version >= 2.5.3 ? ... yes (2.6.3) Git version >= 2.21.0 ? ... yes (2.21.0) Git user has default SSH configuration? ... yes Elasticsearch version 5.6 - 6.x? ... skipped (elasticsearch is disabled)
Checking GitLab App ... Finished
Checking GitLab subtasks ... Finished
Background
The relevant code seems to be this:
pipeline = all_matching_pipelines.first
ref = params[:ref]
ref ||= pipeline&.ref
ref ||= user_project.repository.branch_names_contains(commit.sha).first
not_found! 'References for commit' unless ref
name = params[:name] || params[:context] || 'default'
pipeline ||= user_project.ci_pipelines.build(
source: :external,
sha: commit.sha,
ref: ref,
user: current_user,
protected: user_project.protected_for?(ref))
pipeline.ensure_project_iid!
pipeline.save!
So if all_matching_pipelines
doesn't return anything, a new pipeline is created.
def all_matching_pipelines
pipelines = user_project.ci_pipelines.newest_first(sha: commit.sha)
pipelines = pipelines.for_ref(params[:ref]) if params[:ref]
pipelines = pipelines.id_in(params[:pipeline_id]) if params[:pipeline_id]
pipelines
end
We do have params[:ref]
, so pipelines.for_ref(params[:ref])
is probably what doesn't find anything on the second API request if pipeline.save!
hasn't yet finished for the first API request.
Possible fixes
Wrap the call in a redis-based exclusive lease when params[:pipeline_id]
is not provided and an existing pipeline is not found. It would also be good to explicitly look up the existing pipeline from the primary database to avoid a race with database replication.
This is a moderately high traffic endpoint, so the change should be feature flagged.
The exclusive lease key would need to have a unique prefix and contain all of the following:
user_project.id
commit.sha
-
params[:ref]
when provided