Distributed Systems · Distributed Transactions
Sagas, Compensations, and the Semantics a Rollback Cannot Restore
How long-running distributed transactions replace atomic rollback with forward recovery, why a compensation is not an inverse, and what the scheduler must guarantee to make it work.
Two-phase commit holds locks from the first vote to the final decision, as described in two-phase commit and the coordinator decision record. For a transaction measured in milliseconds, that is a bounded cost. For a transaction that waits on a human approval, calls an external payment provider, or runs for hours, the cost is unacceptable: every row it touched is unavailable to everyone else for the duration. Sagas are the alternative for that class of transaction, and they replace atomicity with a weaker but operational property.
The structure: a sequence with compensations
A saga decomposes a long transaction into a sequence of local transactions, each of which commits on its own node and releases its locks immediately. Each step has a compensating action that reverses its effect. If a step fails, the saga runs the compensations for the steps that already committed, in reverse order, and the overall effect is undone.
The decomposition is not a partition of one atomic operation. Each local transaction is independently durable, so at any instant the system is in a state where some steps have committed and others have not. That intermediate state is visible to other transactions, which is the fundamental difference from two-phase commit: isolation is given up, not just atomicity.
The compensating action must be defined by the application, not derived by the system. A database can undo a row update because it kept the old value in the undo log. A saga step that sent an email, charged a card, or reserved inventory cannot be undone by restoring a page; it needs a new action that semantically reverses the first: a refund, a cancellation, a release.
Why a compensation is not an inverse
The word “compensation” is chosen deliberately, because these actions are not inverses in the algebraic sense. Several properties separate them.
A compensation may fail. A refund can be declined; a cancellation can be refused because the goods shipped. When a compensation fails, the saga cannot proceed backward and cannot proceed forward without leaving an inconsistency. The scheduler must decide: retry the compensation, escalate to a human, or leave the saga in a state that requires reconciliation. This is a state the two-phase commit protocol does not have, because a committed participant never needs to run anything after the decision.
A compensation may be observable. A customer who was charged and then refunded has seen both events. The intermediate state is not hidden, and no compensation can remove the fact that it occurred. This is why sagas suit business processes where the intermediate state is meaningful and acceptable, and why they are a poor fit for invariants that must never be observed broken.
A compensation may not restore the exact prior state. If a step incremented a counter, the compensation decrements it, and the counter’s value is restored only if no other transaction touched it in between. If another transaction read the counter while the saga was in progress, the compensation cannot unmake that read. The saga’s guarantee is that the final state is consistent, not that the intermediate states were invisible.
A compensation may need to be idempotent. Retries are inevitable, so a compensation that runs twice must not refund twice. The usual implementation is a request identifier that the downstream service deduplicates on, which is the same mechanism used for retrying any non-idempotent operation. Without it, a saga’s recovery path can itself cause harm.
What the scheduler has to guarantee
The saga’s execution is a state machine, and the scheduler’s job is to make it progress in the presence of failures. Three properties are needed.
Every step must be retried until it succeeds or is definitively rejected. A step that times out is not a failure; it is an unknown outcome, and the scheduler cannot assume the downstream did nothing. The safe pattern is to retry with the same idempotency key, which either completes the original operation or returns its result.
The compensation order must be reverse, and each compensation must be retried until it succeeds or is escalated. A compensation that is skipped leaves a committed effect that will never be undone.
The saga’s state must be durable and external to the participants. The scheduler itself can crash, and on restart it must know which steps committed and which compensations ran. Storing the state in the same database as the saga’s data is a common shortcut that fails when that database is the one that lost the writes: the state and the effects disappear together, and the saga cannot be recovered.
The durability requirement makes the scheduler a participant in the saga, with its own write path. A scheduler that records each step transition durably pays a write per step, which is the same order as the step’s own work. That cost is usually acceptable because the steps are coarse, unlike the fine-grained records of a two-phase commit participant.
Isolation: the part that is genuinely lost
The original formulation allows a saga to be executed without any isolation, on the argument that the intermediate states are visible but the final state is consistent. In practice, the visibility causes anomalies that matter, and several techniques exist to reduce them.
A semantic lock reserves a resource so that other transactions treat it as unavailable or as in-use. A reservation on an inventory item is a row that says “held by saga S”, and other readers either wait or see it as unavailable. The lock is held in the data rather than in a lock manager, so it survives the scheduler’s restart, and it is released by a compensation.
A countermeasure is a compensating action taken preemptively: a step that might need to be undone is paired with an action that makes its effects invisible until the saga completes, such as writing to a pending table and moving the rows to the live table only at the end. That converts the saga into a two-phase operation on the data, with the pending table as the prepared state, which is why the pattern is sometimes called a saga with a staging area.
The pivot transaction is a scheduling concept: the point in the sequence after which the saga cannot be compensated and must complete. Before the pivot, failures lead to backward recovery; after it, failures lead to retries and forward recovery. Identifying the pivot explicitly is what allows the scheduler to decide whether to compensate or to push through, and a saga without a defined pivot tends to make that decision inconsistently at different failure points.
Failure modes at the boundaries
The interesting failures are not the ones in the middle of a step but at the transitions.
A crash between a step’s commit and the scheduler’s record of it leaves the scheduler believing the step did not commit. On recovery it retries the step, which is why the step must be idempotent, or it compensates a step that never ran, which is why the compensation must be able to determine whether the step’s effect exists.
A crash between the decision to compensate and the first compensation leaves the scheduler with a decision it must honour. On recovery it must resume compensation from where it stopped, which requires the compensation progress to be recorded as durably as the forward progress.
A downstream service that applies a step twice because of a retry produces a duplicated effect that the compensation then reverses once, leaving one net effect. The idempotency key is the only defence, and it must be honoured by the downstream service, which makes it a contract between the saga and every participant rather than a property of the scheduler.
A compensation that is not the last action leaves the saga partially compensated. The scheduler must continue compensating the remaining steps, which means the compensation sequence is itself a saga with the same retry and durability requirements. Recursion is bounded by the number of steps, but the state to track is the same order.
Where the boundary with atomic commit lies
The choice between a saga and a two-phase commit is not a matter of preference. It is determined by three properties of the transaction.
Duration determines whether lock holding is tolerable. A transaction that finishes in milliseconds can hold locks; one that waits on an external system for minutes cannot, because the lock is a resource consumed by every competing transaction.
The presence of external effects determines whether rollback is even meaningful. A transaction that only changes rows in databases can be undone by the databases. One that sends a message to a third party cannot be undone, and needs a compensation that the third party implements.
The strength of the isolation requirement determines whether intermediate states can be visible. A saga exposes partial states; if the application’s invariants cannot tolerate that, the saga needs semantic locks or a staging area, and the cost approaches the cost of the atomic protocol it was meant to avoid.
The last point is the one most often missed. A saga is not a cheaper two-phase commit; it is a different guarantee. It provides atomicity in the sense that the saga eventually reaches either all-done or all-compensated, and it does not provide isolation. Systems that need both, for a long-running transaction, need the staging pattern plus semantic locks, which is a substantial engineering effort that is usually justified only for a small number of critical processes.
Compensations as a design constraint on the steps
Because compensations must be written by hand, the saga’s step boundaries are a design decision with consequences that reach into the data model. A step that performs several effects must have a compensation for each, or it must be split so that each effect is its own step with its own compensation. A step whose effect is not reversible by any action the system can take is not a candidate for a saga at all; it must be placed after the pivot, or the saga must be redesigned around it.
That constraint usually improves the design. Steps that are small and reversible are easier to retry, easier to observe, and easier to reason about than a single large operation. The cost is more state to track and more messages, which is the same trade as decomposing a transaction into more participants in two-phase commit: granularity buys recoverability and costs coordination.
The related mechanism for enforcing cross-step invariants is the pending state discussed above, which is a form of the staging pattern used in serializable snapshot isolation across shards under a different name: a version that exists but is not yet visible. Both rely on the same idea, that the system can hold an effect in a state where it is neither applied nor absent, and both pay for it with the bookkeeping that state requires.
References
- Sagas — Hector Garcia-Molina and Kenneth Salem, ACM SIGMOD, 1987
- Concurrency Control and Recovery in Database Systems — Philip Bernstein, Vassos Hadzilacos and Nathan Goodman, Addison-Wesley, 1987