Apply Locking and Parallelism#
Atlantis enforces two distinct lock layers before executing an apply command:
-
Global apply lock β a persistent, operator-managed lock (via
atlantis apply lock) that disables all applies cluster-wide. Implemented byApplyClientbehind theApplyLockChecker/ApplyLockerinterfaces inserver/core/locking/apply_locking.go. -
Pull-level working-dir lock β an in-memory, per-PR mutex that serializes concurrent apply commands on the same pull request and blocks applies while a plan is in flight. Implemented by
WorkingDirLocker.TryLockPullinserver/events/working_dir_locker.go. Introduced in PR #6605 to prevent applying a plan whose PR head commit or base branch has changed since the plan was generated.
The top-level entry point is ApplyCommandRunner.Run in server/events/apply_command_runner.go.
Execution Order in ApplyCommandRunner.Run#
Lock acquisition happens before project/workspace resolution β an ordering with significant implications for multi-instance setups :
| Step | Action | Notes |
|---|---|---|
| 1 | Pre-workflow hook guard | Exits early for globally ignored targets |
| 2 | Directory-targeted early exit (skipIgnoredTargetedDirBeforeApplyLocks) | For -d applies: resolves projects and silently skips if the directory is ignored β before acquiring the pull lock |
| 3 | Global apply lock check (IsLocked) | Rejects all applies if a global lock is active |
| 4 | DisableApplyAll guard | Blocks flag-less atlantis apply if disabled |
| 5 | Pull-level lock (TryLockPull) | Acquires the PR-wide lock; fails immediately if another command holds it or a plan is in flight |
| 6 | Plan state refresh | Validates that the stored plan is still fresh relative to the live PR head and base |
| 7 | Project/workspace resolution (BuildApplyCommands) | Determines which projects match the command |
| 8 | Parallel project execution | Runs matched projects via runProjectCmdsWithCancellationTracker |
Because step 5 precedes step 7, a targeted -p <project> apply acquires the pull lock before Atlantis knows which projects it owns β the root cause of a multi-instance ordering bug described below .
Lock Granularity and Intra-PR Parallelism#
Two nested lock scopes control concurrency within a single pull request :
| Lock | Key pattern | Scope |
|---|---|---|
Pull-level (TryLockPull) | repo:pull/.pull | Entire PR β one command at a time |
Workspace-level (TryLock) | repo/pull/workspace/path/projectName | Per workspace+project tuple |
Pull-level lock: only one apply (or plan) may hold this at a time. It also scans existing workspace locks for an in-flight plan and blocks the apply if one is found , preventing apply from racing against a concurrent atlantis plan.
Workspace-level lock: scoped to repo/pull/workspace/path/projectName. PR #6086 added projectName to the key, so projects with different names sharing the same workspace/path can lock independently and run in parallel within a single apply invocation.
Parallel apply within one invocation: once the pull-level lock is held, matched projects can run concurrently. isParallelEnabled returns true when ParallelApplyEnabled is set on the first project context . Each project then contends only on its own workspace-level lock.
Net effect: across separate apply invocations on the same PR, applies are strictly serialized by the pull-level lock; within a single apply invocation across multiple projects, they can run in parallel via workspace-level locks.
Multi-Instance and Multi-Replica Complications#
Lock-Before-Project-Resolution Bug#
In deployments where multiple Atlantis instances each own a disjoint set of projects, a targeted atlantis apply -p project-b is broadcast to all instances. An instance that doesn't own project-b still acquires the pull-level lock before discovering it has no matching projects. If another instance is concurrently running an apply on the same PR, the non-owning instance posts a lock-conflict error β even when --silence-no-projects should suppress it .
PR #6811 fix (ShouldSilenceTargetedApply): for -p-targeted applies with --silence-no-projects enabled, checks whether the named project exists in this instance's config before acquiring the pull lock. If the project is unowned and no plan is in flight, the command is silently skipped. This is complementary to skipIgnoredTargetedDirBeforeApplyLocks (which handles -d directory-targeted applies); the two cover different targeting modes .
Cross-Replica Concurrency with Shared Working Directories#
DefaultWorkingDirLocker is an in-memory map guarded by a sync.Mutex β it cannot coordinate across processes. When multiple Atlantis replicas share a working directory (e.g., via a shared filesystem), two replicas can simultaneously operate on the same PR, risking corruption (e.g., incompatible Terraform provider lockfiles).
PR #6825 fix (LeasedWorkingDirLocker): wraps the local locker with a Redis-backed lease at the pull level:
TryAcquirePullLeaseβ atomic acquire with TTL (owner-only).RenewPullLeaseβ periodic renewal; if renewal fails near expiry, the process fail-stops (panics) before another replica can take over.ReleasePullLeaseβ owner-only release.
Project-level locks remain local (in-memory), so intra-instance parallelism across projects is preserved. The Redis lease layer is wired in only when Redis is configured; single-instance deployments use the local locker unchanged .
Redis Lock Storage Details#
Project-level locks in Redis use the key format pr/{repoFullName}/{path}/{workspace}/{projectName} . PR #6086 added projectName to this key; migrateOldLockKeys migrates old keys on startup using SCAN (compatible with Redis Cluster) rather than KEYS. Ownership-checked cleanup (UnlockIfOwnedByPull) uses an atomic Lua script to delete a lock only when its pull number matches .
Key Files#
| File | Purpose |
|---|---|
server/events/apply_command_runner.go | Top-level apply orchestration; lock check ordering |
server/events/working_dir_locker.go | Pull-level (TryLockPull) and workspace-level (TryLock) lock implementations |
server/core/locking/apply_locking.go | Global apply lock (ApplyLockChecker/ApplyLocker/ApplyClient) |
server/core/redis/redis.go | Redis-backed project lock storage; key format, SCAN-based listing, Lua-guarded ownership unlock |
server/core/redis/pull_lease.go | Redis acquire/renew/release pull-level lease (added by PR #6825) |
server/events/leased_working_dir_locker.go | LeasedWorkingDirLocker wrapping local locker with Redis lease (added by PR #6825) |
Related PRs:
- PR #6605 β introduced the pull-level lock to prevent stale plan application
- PR #6086 β added
projectNameto workspace lock key for independent parallel applies - PR #6811 β silences unowned named-project applies before pull locking in multi-instance setups
- PR #6825 β Redis-backed leased locker to prevent cross-replica concurrency on shared working directories
Issue:
- Issue #6786 β documents the lock-before-project-resolution ordering bug in multi-instance setups