Serialize default tracked context creation per project
What does this MR do and why?
Security::ProjectTrackedContexts::CreateOrUpdateDefaultTrackedContextWorker listens for three events (Projects::ProjectCreatedEvent, Repositories::DefaultBranchChangedEvent, Repositories::RepositoryCreatedEvent), and each one can call Security::ProjectTrackedContexts::FindOrCreateService.project_default_branch(project).execute for the same project around the same time, for example when a project is created.
The service checks whether a default-branch tracked context already exists before creating one, but that check and the creation are not atomic. Under concurrency, two calls can both see "no existing default context" before either one commits, so both end up creating a row with is_default: true. This leaves duplicate default tracked context rows in security_project_tracked_contexts for the same project.
This MR fixes it by serializing the check-then-create logic with a Redis exclusive lease (in_lock), using double-checked locking:
- Lock-free fast path: the service first checks for an existing context without taking the lock. Since finding an existing context is the overwhelmingly common case (including the synchronous path from the
Mutations::Vulnerabilities::CreateGraphQL mutation), web request threads almost never touch Redis. This follows theGitlab::ExclusiveLeaseHelpersguidance against blocking webserver threads on retryable locks; the retry budget is also kept small (3 retries × 0.2s sleep) to bound worst-case blocking. - Narrowly scoped lock: the lease only wraps the mutating section, and the key is scoped to the default-branch context (
...:<project_id>:default) foris_defaultcalls, or to the specific ref (...:<project_id>:<type>:<name>) otherwise. Concurrent creations of different refs for the same project don't serialize against each other. - Re-check inside the lock: after acquiring the lease, the service clears its memoization and re-checks for an existing context, so a row created by a concurrent process between the fast path and lock acquisition is found and returned instead of duplicated. This is what closes the original race.
This is MR 1 of 3 for the linked work item: this MR adds the application-level lock. Follow-up MRs will add model-level validation and a database unique index.
One deviation from the issue: the issue's suggested snippet used project.with_lock, but that opens a transaction on the main database, while security_project_tracked_contexts lives on the sec database. Using with_lock here would create a forbidden cross-database transaction. This MR uses a Redis exclusive lease (in_lock) instead, which the issue also lists as an alternative.
References
Related to #629757
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.