Skip to main content
RunBook Academy

← All break/fix scenarios in Terraform

intermediateterraform-state~20 min

State Lock Held While Apply Is In Progress

Reported symptoms

  • ●`terraform plan` fails to acquire the state lock; the lock names an apply operation held by a CI runner
  • ●The CI job has printed nothing for thirty-eight minutes, while the job page still reports it as running
  • ●The lock is forty-five minutes old, past the team's thirty-minute stale threshold
  • ●After a force-unlock, the on-call engineer's apply fails partway through with the provider reporting that objects it is creating already exist
  • ●The CI job then finishes and reports success
  • ●The next plan proposes to destroy objects nobody asked to destroy, and to create objects that are plainly there

Evidence

  • · The lock information printed by the failing plan: id, operation, holder, version, creation time and path
  • · The team runbook's stale-lock rule, and the wall-clock time it was applied at
  • · The CI job's status from the platform API, and the timestamp of its last log line
  • · The provider's own audit trail of API calls made by that runner's session during the silent window
  • · The state serial before and after each of the two writes
  • · The two apply logs side by side, over the objects they both touched
Diagnosis and resolutionclick to reveal

Root cause

The lock was not stale. The CI apply was alive and mid-flight, waiting on a provider create that legitimately takes tens of minutes, and its silence was a buffered log rather than a dead process. The team's rule declares a lock stale once it is thirty minutes old, but lock age measures how long an operation has been running, which is not evidence about whether it is still running - a long apply and a dead apply produce the same number. Force-unlock then did precisely what it is documented to do: it removed the mutex, without asking the holder, while the holder was still writing. Two applies were now mutating the same infrastructure and the same state at once. Each read a state that did not contain the other's work, so each planned against a world that no longer existed: the second tried to create objects the first had already built, and when the writes landed, the later one overwrote the earlier and the surviving state lost every record the earlier had written. The precise interleaving depends on the backend and on timing, which is the point - the outcome is not reconstructable by reasoning, only by evidence.

Remediation

The first correct action is the one that was skipped: hold, and ask the holder. A blocked plan is not an outage. Name an owner for the hold and an end time, then establish liveness from a system that is not one of the two already misleading you - the provider's own audit trail will show API calls from that session if the apply is alive. Delivery of those records can lag by minutes, so read the lag rather than assuming it away; a gap of minutes is still an answer about the process, where a clock is not. Once the collision has happened, stop every apply against that state before anything else, and preserve every backend version from the incident window; those versions are the only record of what each apply wrote. Then reconcile object by object rather than state file by state file: reconstruct what each apply did from its own log and from the provider audit trail, import the objects that exist and are missing from state, and remove the entries whose objects have been confirmed absent. Do not restore an older state version wholesale to make the plan quiet, because a wholesale restore discards everything both applies did and replaces one unknown with a larger one.

Verification

The plan is empty and every line of the path to it is explainable. An empty plan reached by removing state entries until the noise stopped is not a verification, it is a state file edited to agree with itself, so the check is that each reconciliation step names the object it acted on and the evidence that justified it. Confirm every object through the provider's own CLI rather than through Terraform, since both Terraform's read and the plan go through the same provider path and a state entry for an object that no longer exists will satisfy neither. Confirm the state serial advanced exactly as many times as there were deliberate writes, and that the surviving state contains the work of both applies. Then run a plan from a second, independent working directory, freshly initialised, to prove the result is a property of the state and not of one operator's cached environment.

Prevention

Force-unlock is an incident-response tool and its precondition is a confirmed dead holder, not an elapsed clock. Replace the thirty-minute rule with a procedure that requires positive evidence of liveness and records who established it. Make that evidence cheap to get: the lock's holder field should name something queryable - a job the on-call can open - rather than a container id nobody can resolve at 22:00. Set `-lock-timeout` to comfortably exceed the longest legitimate apply, so contention waits instead of failing and the human is never handed a decision they did not need to make. Serialise applies at the pipeline as well as at the backend, so a second apply is not merely rejected but never started. Apply from a saved plan everywhere, including the break-glass path, because a saved plan refuses to run once the state it was built from has moved on - the interactive apply in this incident is the only kind that nothing would have stopped. And stream apply logs unbuffered, so that silence is real evidence rather than an artefact of the runner.

Reported symptoms

21:41 on a Friday. The release pipeline starts an apply against the production state. It is a large change: a new database tier, a handful of network objects, and the wiring between them.

22:26. An on-call engineer needs to ship an unrelated one-line fix and runs a plan against the same state. It fails:

Error: Error acquiring the state lock

Error message: ConditionalCheckFailedException: The conditional request
failed, because the lock was acquired by another process or the lock was
force-unlocked.
Lock Info:
  ID:        8b41f0c2-0a77-4e5f-9d3c-1f6b2a904ee1
  Operation: OperationTypeApply
  Who:       runner@fv-az713-4
  Version:   1.9.8
  Created:   2026-08-14 21:41:07.318 +0000 UTC
  Path:      acme-tfstate/production/terraform.tfstate

The engineer checks the pipeline. The job is listed as running. Its last log line was written at 21:48 — thirty-eight minutes ago. Nothing since.

The team runbook has a rule, added after a genuinely stuck lock last year: if the lock is more than thirty minutes old and the job shows no progress, force-unlock. The lock is forty-five minutes old and the job shows no progress. The engineer force-unlocks and runs an interactive apply.

What happens next arrives out of order and looks like four separate problems:

  • 22:52 — the interactive apply fails partway through, with the provider rejecting several creates because the objects already exist.
  • 23:04 — the CI job finishes, and reports success.
  • 23:10 — a fresh plan proposes to destroy objects nobody asked to destroy, and to create objects that are visibly present in the console.
  • 23:20 — the new database tier is reachable and serving, and appears nowhere in state.

Evidence provided

The CI platform’s own view of the job during the silent window:

Read-only / Safethe platform believes the step is running; the log has said nothing for 38 minutes
$ gh run view 2214877 --json status,startedAt,jobs --jq '.status, .jobs[].steps[-1]'
in_progress
{"name":"terraform apply","status":"in_progress","conclusion":null}

Illustrative output

The provider’s audit trail for that runner’s session, over the same window:

Read-only / Safea describe call every sixty seconds - this process is alive and waiting
$ aws cloudtrail lookup-events --lookup-attributes AttributeKey=Username,AttributeValue=terraform-apply --start-time 2026-08-14T21:48:00Z --max-results 5 --query 'Events[].[EventTime,EventName]' --output text
2026-08-14T22:24:11Z    DescribeDBInstances
2026-08-14T22:23:11Z    DescribeDBInstances
2026-08-14T22:22:11Z    DescribeDBInstances
2026-08-14T22:21:11Z    DescribeDBInstances
2026-08-14T22:20:11Z    DescribeDBInstances

Illustrative output

The state serial across the evening, read from the backend’s own version history:

version   written              serial   note
--------  -------------------  -------  ------------------------------------
v41       2026-08-14 21:41     417      lock acquired by CI apply
v42       2026-08-14 22:52     418      written by the interactive apply
v43       2026-08-14 23:04     419      written by the CI apply

And the two apply logs, over one of the objects they both touched:

CI apply           21:52  aws_db_subnet_group.core: Creation complete
interactive apply  22:51  aws_db_subnet_group.core: Creating...
interactive apply  22:52  Error: DBSubnetGroupAlreadyExists

Work the evidence before reading on

The runbook rule was followed exactly and the outcome was an incident. That means the rule is measuring the wrong thing.

  1. The lock says Created: 21:41 and it is now 22:26. Write down what that forty-five minutes actually tells you about the process holding the lock. Then write down what it does not tell you.
  2. The job log has been silent for thirty-eight minutes and the audit trail shows an API call every sixty seconds. Two systems, two answers. Which one is reporting on the process, and which is reporting on a stream?
  3. The interactive apply hit AlreadyExists on an object the CI apply had created an hour earlier. Where did the interactive apply get its picture of the world, and why did that picture not include the object?

Before continuing: serial 418 was written at 22:52 and serial 419 at 23:04, and each apply built its write from a state it read before the other one wrote. What is in serial 419, and — more usefully — what is not?

Root cause

1. The lock was doing its job

Apply is the only Terraform operation that holds the state lock for an extended period: it takes the lock at the start and holds it until the final state write completes. For a change that creates a database tier, that window is measured in tens of minutes, and for the whole of it the lock is correctly held by a healthy process.

A second operator being refused during that window is not a fault. It is the mutex preventing exactly the outcome that followed.

2. Lock age is not evidence about liveness

The runbook rule reads a number that is easy to obtain and asks it a question it cannot answer. Created records when the operation started. A forty-five-minute-old lock is consistent with a healthy apply provisioning slow infrastructure, and equally consistent with a runner that was killed forty-four minutes ago. The number is identical in both cases.

The rule was written after a genuinely stuck lock, and it encoded the symptom of that incident rather than the finding. The finding should have been “confirm the holder is dead”; what got written down was “wait thirty minutes”, because that was the part that was easy to check.

3. The silence was a buffer, not a death

The job’s log had not advanced in thirty-eight minutes because the apply was inside a single long-running create, and because the runner’s output was buffered. Nothing had gone wrong. The one signal that comes from neither the CI platform nor Terraform — the provider’s own audit trail — showed a describe call every sixty seconds, which is what an apply polling a create looks like from outside.

That check takes seconds and it is decisive. It was never run, because the runbook did not ask for it.

4. Force-unlock removed the mutex from a live writer

force-unlock deletes the lock record. It does not signal the holder, it does not wait for it, and the holder is never told. The CI apply continued, unaware, and kept building.

The interactive apply then read the state as it stood at 21:41 — before the CI apply had written anything — and planned against that. So it proposed to create objects the CI apply had already created, which is why it hit AlreadyExists and stopped partway through. It then wrote what it believed at 22:52, producing serial 418. Twelve minutes later the CI apply finished its own work and wrote what it believed, producing serial 419.

Serial 419 is the survivor, and it was built from a read taken at 21:41 plus the CI apply’s own changes. Everything the interactive apply created between 22:26 and 22:52 is absent from it. Those objects exist, are running, cost money, and are managed by nobody — which is why the 23:10 plan proposed to build them again, and proposed to destroy the ones it had a stale opinion about.

Resolution

  1. Before anything else, in the general case: hold and ask the holder. Name an owner for the hold and an end time. A blocked plan is not an outage, and the cost of waiting forty minutes is almost always smaller than the cost of being wrong about a live writer.
  2. Establish liveness from outside both systems. Check the provider audit trail for API calls from that session in the last few minutes; on a shared host, check for a running process. Record who checked and what they saw, because this is the fact the decision rests on.
  3. Once the collision has happened, stop every apply against this state - pipeline disabled, humans told. A third writer arriving during the recovery makes the reconstruction impossible rather than merely difficult.
  4. Preserve every backend version from the incident window before touching anything. Those documents are the only record of what each apply believed it had done, and a recovery that overwrites them destroys its own evidence.
  5. Reconstruct what each apply actually did from its own log and from the provider audit trail. Build one list of objects created, changed and destroyed, per apply, with times. Do not build this list from state; state is the artefact under suspicion.
  6. Reconcile object by object against the provider. For each object on either list, establish whether it exists now and whether the surviving state has an entry for it. This is slow and it is the work; there is no command that does it.
  7. Adopt the orphans. Objects that exist and have no state entry are imported, one at a time, with the plan checked after each. These are the objects the losing writer created, and until they are adopted nothing manages them.
  8. Remove the entries whose objects are confirmed absent, and only after the provider has confirmed absence directly. A state entry removed on the strength of a plan diff is a guess.
  9. Do not restore an older state version wholesale to make the plan quiet. A restore discards everything both applies did and replaces a known partial problem with a larger unknown one; it is the right tool for corruption, not for divergence.
  10. Only then run a plan, and read it rather than applying it. A plan that is still proposing destruction after the reconciliation means the reconciliation is incomplete, and forcing it through is how a recoverable incident becomes an outage.

Verification

  1. The plan is empty and every step that got it there is written down with the evidence that justified it. An empty plan produced by deleting state entries until the noise stopped is a state file edited to agree with itself, which is the failure mode this whole incident is made of.
  2. Every object on both reconstructed lists has been accounted for as present-and-managed or confirmed-absent-and-removed. A single unaccounted object is an orphan, and an orphan is invisible until the day it breaks.
  3. Object existence is confirmed through the provider CLI, not through Terraform. Terraform read and the plan go through the same provider path; an out-of-band call is the only independent check.
  4. The surviving state contains the work of both applies. Spot-check the objects each one created, particularly those created by the writer whose document did not survive.
  5. A plan run from a second, freshly initialised working directory agrees. This proves the result is a property of the state rather than of one cached working directory.
  6. The next real apply is watched end to end, with the lock observed being acquired and released. The first apply after a state incident is a verification step, not a routine change.

Prevention

  • Rewrite the stale-lock rule around evidence, not elapsed time. The precondition for force-unlock is a confirmed dead holder. The runbook should name the check that establishes it, and require the person who ran that check to be recorded.
  • Make the holder identifiable. The lock’s holder field is a user and a hostname. On ephemeral runners that resolves to a container nobody can look up at 22:00. Name runners after the job they are executing so the on-call can open the holder instead of guessing about it.
  • Set -lock-timeout to comfortably exceed the longest legitimate apply. A plan that waits is a plan that does not hand a tired engineer a decision. Relying on the default retry behaviour is what turned a scheduling overlap into a judgement call.
  • Serialise applies at the pipeline as well as at the backend. A concurrency group means a second apply is never started rather than merely refused, and a never-started apply cannot be unblocked by removing a lock.
  • Apply from a saved plan, including on the break-glass path. A saved plan records the state it was built from and refuses to run once that state has moved on. The interactive apply in this incident is the one form nothing would have stopped.
  • Stream apply logs unbuffered. Silence should mean the process is silent, not that the runner is holding the output. Here the buffering supplied the only evidence anyone acted on, and it was an artefact.
  • Treat a long apply as a known operating condition. If a change routinely holds the lock for forty minutes, publish that, and let the next engineer recognise the lock rather than diagnose it.