Skip to content

When apply fails

A plan can fail part-way through. This page describes what the engine does next.

atlantide apply --on-failure rollback   # default: undo what this run completed
atlantide apply --on-failure halt       # stop where it stands, undo nothing

The three phases

Apply runs in phases. Only the first is reversible.

Phase What runs Order Reversible
1 CREATE, UPDATE, REPLACE dependencies first, in parallel yes
1b the destroy half of a create-before-destroy REPLACE dependents first no — terminal
2 DELETE dependents first no — terminal

Phases 1b and 2 are terminal: recreating a destroyed resource would lose its identity and outputs, so a failure there is reported, not compensated. Committed stack outputs are persisted only after all three phases succeed.

The compensation saga

Under the default --on-failure rollback, every node that completes in phase 1 records an undo. On failure the undos run in reverse completion order, not reverse graph order, so they follow what this run actually did.

What was applied What the undo does
CREATE provider.delete, then drop the state row. The resource carries the outputs of its create — notably its id — so the delete acts on that resource directly rather than locating it by attributes, which could match an unrelated resource sharing them.
UPDATE provider.update back to the prior values, then restore the prior state row verbatim, still sealed.
REPLACE Undo the create half, then restore the prior resource and its recorded outputs.

The saga catches BaseException, not Exception. An interrupt arrives as a cancellation; catching only Exception would skip compensation on a Ctrl-C during an apply that has already created resources.

When rollback does not run

A run that lost its lease is not rolled back. A compensation is a provider call and a state write, and a run that no longer holds the lock may be racing the run that took it; undoing "its" creates could destroy the other run's resources. The resources are left in place, and the report states this.

This is the only case in which --on-failure rollback does not roll back. Recover with atlantide refresh.

When rollback itself fails

An undo is a provider call followed by a state write. A failure between the two leaves state describing a resource that no longer exists. Because the diff is symbolic, that row's stored hash still matches config, so the next plan would report NOOP and the divergence would stay hidden permanently.

To prevent this, the engine poisons the row before running a node's compensation: it clears the row's input_hash so the next plan cannot Merkle-skip it. The undo's own state write clears the poison when it succeeds. Poisoning afterwards would only take effect on an exception; a process killed mid-rollback would leave the row untouched and the divergence silent.

When compensations fail, the original error and the rollback errors are raised together as a group, and every leaf is rendered. The rollback failure never replaces the apply failure.

Interrupts

The first Ctrl-C cancels the run and follows the failure path above: under the default, completed nodes are compensated on the way out. The exit code is 130.

A second Ctrl-C abandons that cleanup. Each compensation is shielded, so the press cannot cancel an undo between its provider call and its state write — the window poisoning makes visible — but the CLI's hard exit still takes effect. State may then not describe what exists: run atlantide refresh before applying again.

The second press does not release the run's lock. An abandoned run may still have provider calls in flight, so the lease is left to lapse, or to be cleared with atlantide state unlock, rather than handed to a second writer.

The re-diff before anything runs

Apply does not execute the changeset that plan printed. Once it holds the lease, it re-diffs against freshly loaded state; otherwise a resource that another run created in the meantime would still be planned as a CREATE and built twice.

The approved plan and the plan about to execute can therefore differ, and that difference could include an unreviewed destroy. When they differ, the run stops with a PlanDriftError naming what appeared and what disappeared.

After a failed run

atlantide refresh          # what actually exists, vs what state records
atlantide state list       # what state records
atlantide state unlock     # who holds the lease

refresh is the recovery command in every case above. It is read-only unless given --write. A resource the provider cannot find is reported but kept unless --prune is also given.

See Troubleshooting for the errors these paths raise.