lumo

Module 10 · Lesson 8 of 12Free

Sagas: multi-step transactions with rollback steps

Level
Advanced
Topic
Consensus, coordination and distributed transactions
Reading time
19 min

When you finish this page you can

  1. Explain how compensation differs from database rollback.
  2. Design a checkout saga with durable progress and safe retries.
  3. Compare orchestration and choreography for a concrete workflow.
  4. Diagnose unknown outcomes, failed compensation and concurrent updates.
  1. Transactions and ACID guarantees15 min
  2. Isolation levels and locking: handling concurrent writes20 min
  3. Two-phase commit: atomic transactions across services20 min

Video lesson · coming soon

reserves one item for Asha, reducing available stock from 10 to 9. Another checkout reserves an item. Then Asha’s payment is declined.

Should Kartly restore stock to 10, or increase it by one?

Kartly now has separate order, inventory and payment services. How can checkout recover from partial success without reversing someone else’s work?

Undo the action, not history

Check yourselfAnswer in your head first, then reveal
Stock starts at 10. Asha reserves one item, then Ben reserves one. Asha’s payment fails. What should available stock become?Show answerHide answer
9, not 10. Only Asha’s reservation should be released; Ben still owns his reservation. Restoring an earlier stock snapshot would make Ben’s item available for another buyer.

A database rollback abandons changes before their commits. Asha’s inventory transaction has already committed. Recovery needs another transaction: release her particular reservation.

A is a sequence of local transactions with recovery actions for partial completion. Each local transaction commits independently. A is a later business action that counteracts an earlier action.1

Diagram not drawn yet: sagas-compensation-preserves-other-orders.svg

Available stock starts at 10. Asha’s reservation reduces it to 9. Ben’s reservation reduces it to 8. Asha’s payment fails, leaving stock at 8. Releasing only Asha’s reservation raises stock to 9; Ben’s reservation remains.

Compensation releases one reservation. It does not restore an old stock snapshot.

The phrase “rollback steps” can suggest a stronger promise than sagas provide. Other requests can observe intermediate committed changes. Compensation also leaves history: a payment hold and its cancellation may both remain visible.2

For this lesson’s scenario, Asha buys one item costing ₹2,400. Checkout reserves stock, authorizes payment, then confirms the order. creates a temporary payment hold, not a completed charge.

The product promise is specific: no order becomes confirmed without stock and authorization. An unsuccessful checkout eventually releases its resources, assuming dependencies recover or an operator resolves the problem.

That promise allows a visible PENDING state. It does not promise that every service changes at one instant.

Choose a recoverable business boundary

Start with three ordinary calls:

  1. Create a pending order.
  2. Reserve its stock.
  3. Authorize payment and confirm the order.

This works until a later call fails. Adding try and except around the calls handles some errors, but not a server crash. Local variables disappear before cleanup runs.

The earlier two-phase commit lesson coordinates a shared commit decision. A saga instead accepts partial commits and defines what happens next.

What consistency boundary should Kartly choose for checkout?

Keep the affected records in one database transaction.

One local transaction can update orders and stock together. This removes cross-service recovery, but requires shared ownership or moving the affected data.

Use .

Participating transaction managers agree on commit or abort. Prepared work can retain resources while a decision is unavailable, and every participant must support the protocol.

Why this choice

Kartly already has independent services. For this scenario, assume its payment provider supports authorization, cancellation and operation lookup, but cannot join Kartly’s database transaction.

A saga fits because pending checkout and later cancellation are acceptable product states. If the records shared one owner and database, prefer a local transaction. If intermediate visibility were forbidden, change the boundary or require an atomic protocol that every participant supports.

Before implementation, give each action an owner and a recovery rule.

Owner Forward action Compensation
Orders Create PENDING order Mark CANCELLED
Inventory Reserve one item by reservation ID Release that reservation once
Payments Authorize ₹2,400 by operation ID Cancel that authorization
Orders Confirm after both successes End this checkout workflow

Order confirmation finishes this saga. Later customer cancellation belongs to a separate workflow with different rules.

Do not dispatch a parcel during these reversible steps. You cannot make a delivered parcel disappear. Place irreversible work behind a deliberate business boundary, then recover forward when reversal is no longer meaningful.2

The remaining question is who remembers the sequence when a worker disappears.

Put progress somewhere durable

puts workflow decisions in a coordinator. distributes those decisions among services reacting to events. Both arrangements can implement sagas.1

Where should Kartly keep checkout’s sequence and recovery rules?

Choreography through service events.

Inventory reacts to OrderPending; payments react to StockReserved. This avoids a separate workflow controller, but cancellation and late replies require rules spread across several services.

Why this choice

Checkout needs one answer to “What remains before this order can finish?” An explicit workflow record makes that answer inspectable.

Choreography is attractive for a short, stable event chain. Here, unknown payment outcomes and compensation make central sequencing worth its cost. The coordinator is a logical role, not necessarily one server: replaceable workers can share durable records.

Diagram not drawn yet: sagas-durable-checkout-orchestrator.svg

The customer calls Orders, which contains the checkout coordinator. Orders stores workflow progress and command intents in its database. Workers command Inventory and Payments. Inventory keeps durable reservations. Payments keeps operation records and calls an external payment provider. Replies advance the saved workflow.

The order database remembers the next action. Worker memory is not the recovery record.
Component Responsibility Talks to State it keeps Failure behavior
Customer app Start and inspect checkout Orders Checkout key Reuses key after reconnect
Orders/coordinator Decide the next action Database, participants Temporary execution only Another worker resumes
Order database Persist progress and intent Orders Orders, workflows, commands Checkout progress stops
Inventory Enforce stock ownership Coordinator Stock and reservations Checkout stays pending
Payments Resolve payment operations Coordinator, provider Operation IDs and outcomes No assumed payment failure
Payment provider Maintain authorization Payments Hold and cancellation status Reconciliation waits

Inventory’s database belongs to Inventory; Payments owns its operation store. Neither service lets the coordinator write those records directly.

The normal write path is:

  1. Orders stores the pending order and initial command intent in one local transaction.
  2. A worker reads the intent and asks Inventory to reserve stock.
  3. Inventory commits its reservation and returns the saved result.
  4. Orders records that result and the payment command intent together.
  5. Payments resolves authorization; Orders then attempts confirmation.
  6. Orders commits CONFIRMED and the workflow’s terminal state together.

The read path is shorter: the app asks Orders for the order’s current status. It does not assemble a status from several services.

Saving intent before sending makes a crashed worker replaceable. It also means a replacement may send the same command again.

Repeat commands without repeating effects

The app supplies an for one checkout intention. Orders stores a unique mapping from customer and key to order. A retry returns that order; changed input with the same key is rejected.

Participants need their own stable operation identities.

Internal contract Request Result and retry rule
PUT /reservations/res-17 Order, product, quantity 200 saved reservation; 409 conflicting input or unavailable stock
PUT /reservations/res-17/release Same reservation identity 200 released; repetition changes nothing
PUT /authorizations/auth-17 Order, amount in minor units, currency 200 resolved outcome; 202 unresolved
PUT /authorizations/auth-17/cancel Same authorization identity 200 resolved cancellation; 202 unresolved

These are Kartly’s illustrative internal contracts, not provider APIs. errors and malformed requests remain ordinary request errors.

Each participant compares repeated input with its stored input. Reusing auth-17 for a different amount must not silently return an unrelated authorization.

Inventory enforces available stock inside one local transaction. It creates the reservation and conditionally reduces . A failed stock check leaves no active reservation. Repeated calls return the existing result instead of decrementing again.

Release changes ACTIVE to RELEASED and increments availability in the same transaction. A second release sees RELEASED and does nothing. The local locking details build on isolation levels and locking.

Do not delete released reservations immediately. Their terminal state stops a delayed reserve request from recreating the reservation. If release arrives before reserve, store a terminal cancellation record for that ID.

Payments has a harder boundary: its database cannot atomically commit a provider call. It records the operation first, uses the same provider key on retries, and queries unresolved operations. These capabilities are requirements for the provider selected in this scenario.

That still leaves a distinction the coordinator must preserve: rejection is not the same as silence.

A timeout leaves work unresolved

Suppose Payments obtains authorization, but its reply never reaches Orders. The customer sees “Payment processing”.

If Orders treats this as a decline, it could release stock while a payment hold remains active. A retry with a fresh identity could also create another hold.

Instead, retain the authorization phase. Retry or query the same operation until its outcome is known. Only a definitive rejection selects the failure branch.

The coordinator’s separates forward work from compensation:

Phase Confirmed success Definitive rejection
RESERVING AUTHORIZING CANCELLED
AUTHORIZING CONFIRMING RELEASING
CONFIRMING COMPLETED VOIDING
VOIDING RELEASING Stay; investigate and retry safely
RELEASING CANCELLED Stay; investigate and retry safely

An unknown outcome leaves every phase unchanged. A definitive confirmation rejection might occur because an order-owned business rule no longer permits checkout.

For this sequence, compensation cancels authorization before releasing stock. This keeps the item reserved while payment cleanup remains unresolved. Releasing stock first could improve availability, but needs an explicit policy for customers whose holds remain active.

A deadline should trigger resolution or escalation, not invent a failure result. Customer cancellation during an unresolved authorization also requires resolving or safely cancelling that same operation.

Now the phase table can become executable logic.

Make transitions small and guarded

The coordinator owns this compact record:

sql
CREATE TABLE checkout_workflows (
  order_id TEXT PRIMARY KEY,
  phase TEXT NOT NULL CHECK (phase IN (
    'RESERVING', 'AUTHORIZING', 'CONFIRMING',
    'VOIDING', 'RELEASING', 'COMPLETED', 'CANCELLED'
  )),
  version BIGINT NOT NULL DEFAULT 0,
  expected_operation TEXT NOT NULL,
  next_attempt_at TIMESTAMPTZ NOT NULL,
  last_error TEXT
);

CREATE INDEX checkout_workflows_due
ON checkout_workflows (next_attempt_at)
WHERE phase NOT IN ('COMPLETED', 'CANCELLED');

The index serves workers finding due, unfinished workflows. A separate command record stores the operation ID, action, immutable request and result.

The following function implements only transition selection. It performs no network calls and makes no claim.

python
from typing import Literal

Outcome = Literal["ok", "rejected", "unknown"]

SUCCESS: dict[str, str] = {
    "RESERVING": "AUTHORIZING",
    "AUTHORIZING": "CONFIRMING",
    "CONFIRMING": "COMPLETED",
    "VOIDING": "RELEASING",
    "RELEASING": "CANCELLED",
}
REJECTION: dict[str, str] = {
    "RESERVING": "CANCELLED",
    "AUTHORIZING": "RELEASING",
    "CONFIRMING": "VOIDING",
}

def next_phase(phase: str, outcome: Outcome) -> str:
    if phase not in SUCCESS:
        raise ValueError("Expected an active phase")
    if outcome == "unknown":
        return phase
    if outcome == "ok":
        return SUCCESS[phase]
    return REJECTION.get(phase, phase)
ts
type Outcome = "ok" | "rejected" | "unknown";

const success = new Map<string, string>([
  ["RESERVING", "AUTHORIZING"],
  ["AUTHORIZING", "CONFIRMING"],
  ["CONFIRMING", "COMPLETED"],
  ["VOIDING", "RELEASING"],
  ["RELEASING", "CANCELLED"],
]);
const rejection = new Map<string, string>([
  ["RESERVING", "CANCELLED"],
  ["AUTHORIZING", "RELEASING"],
  ["CONFIRMING", "VOIDING"],
]);

function nextPhase(phase: string, outcome: Outcome): string {
  const target = success.get(phase);
  if (target === undefined) {
    throw new Error("Expected an active phase");
  }
  if (outcome === "unknown") return phase;
  if (outcome === "ok") return target;
  return rejection.get(phase) ?? phase;
}

The surrounding worker must validate that a reply matches expected_operation. It then commits the result, phase change and next command intent together.

Two workers may read the same version. Use a conditional update:

sql
UPDATE checkout_workflows
SET phase = $1,
    expected_operation = $2,
    next_attempt_at = $3,
    version = version + 1
WHERE order_id = $4
  AND version = $5
  AND expected_operation = $6
RETURNING version;

Only the winning transaction inserts the next intent. A worker that updates zero rows reloads instead of advancing stale state.

This protects local decisions, not remote effects. Stable participant operation IDs protect repeated remote commands. Both mechanisms are necessary.

Recovery includes unfinished compensation

Compensation can fail, and sagas provide no automatic isolation across services.2 Kartly must therefore treat unfinished recovery as durable work, not an exception message.

Failure Customer sees Detection Recovery
Worker crashes after reservation Pending checkout Due workflow has no recorded result Repeat the same reservation operation
Authorization reply disappears Payment processing Unresolved payment operation Query or repeat using its stable identity
Provider cancellation is unavailable Cancellation pending Increasing compensation age Retry safely; escalate unresolved holds
Inventory release fails Cancellation pending Old RELEASING workflow Resume release; retain reservation identity
Old reply arrives after progress No status reversal Operation or version mismatch Ignore it for transition purposes

Retry transient failures with increasing delays and randomness. Do not let thousands of recovering workflows hammer an unhealthy dependency. Separate forward-work capacity from compensation capacity so new checkouts cannot starve cleanup.

A compensation attempt limit should move work to operator attention, not erase it. Keep the obligation visible until the effect is resolved. CANCELLED means cleanup completed under this contract, not merely that the customer requested cancellation.

Reservations also need a lifetime policy. If Inventory expires them independently, Orders must validate or finalize the reservation before confirmation. Otherwise payment could succeed after stock has been returned to sale.

For this lesson, reservations remain active until explicit release or successful checkout handoff. Production retention and expiry rules must account for unresolved payment operations.

Common mistakes

  • Calling compensation a database rollback. It is a new committed action. It cannot hide everything observers already saw.
  • Sending a new operation ID after timeout. That creates a new intention instead of resolving the existing one.
  • Restoring an old stock value. Release the identified reservation; preserve other buyers’ changes.
  • Keeping recovery only in memory. A crash must leave enough durable information for another worker.
  • Treating events as proof of delivery. Saving a phase does not prove that the next service received its command.

Going deeper

Isolation and the limits of eventual recovery

A pending reservation acts as a business-level exclusion rule. Other buyers cannot spend its stock, even though the database transaction that created it has ended. Every purchase path must honor that rule.

This does not make the whole saga serializable. Reports can still observe a pending order beside an active authorization. Define which states each reader may consume rather than pretending partial completion is invisible.

Recovery guarantees are conditional. Permanent provider failure, missing operation history or an action with no acceptable compensation can prevent automatic completion. Preserve evidence and define manual recovery procedures before shipping.

Sagas also do not require a . Durable workers can call participant APIs directly. A broker changes transport and buffering; it does not supply compensation rules or make duplicate effects safe.

Where this shows up

The next problem is making a saved transition produce its outgoing message despite a crash, covered in Transactional outbox.

Check your understanding

Check yourselfAnswer in your head first, then reveal
Inventory commits a reservation, then the worker crashes before saving the reply. What prevents another item from being reserved after restart?Show answerHide answer
The replacement repeats the same reservation ID, and Inventory returns its stored result. The workflow version alone cannot prevent duplicate remote effects.
Check yourselfAnswer in your head first, then reveal
Payment authorization times out. Can the coordinator treat the order as declined and start a new payment operation?Show answerHide answer
No. The authorization may already exist, so the outcome remains unknown. Resolve the original operation using its stable identity before choosing a recovery branch.
Check yourselfAnswer in your head first, then reveal
A release request succeeds twice because the coordinator retries. Should stock increase twice?Show answerHide answer
No. Inventory changes the reservation from ACTIVE to RELEASED once and increments stock in that same transaction. The repeated release returns the terminal result.
Check yourselfAnswer in your head first, then reveal
Payment cancellation has failed for an hour. Can the workflow become CANCELLED because its retry limit was reached?Show answerHide answer
Not under this lesson’s contract. Keep cancellation pending and escalate the unresolved obligation; a retry limit does not cancel a real payment hold.
Check yourselfAnswer in your head first, then reveal
A product requirement forbids anyone from observing partial checkout state. Is adding more compensations enough?Show answerHide answer
No. Compensation repairs committed effects later; it does not provide isolation. Change the transaction boundary or choose an atomic protocol supported by all participants.

Summary

  1. A saga recovers from partial success through durable progress and compensations that preserve other requests’ work.
  2. Each step commits locally; pending states and intermediate visibility are part of the contract.
  3. An orchestrator records the next action, while stable operation IDs make repeated commands safe.
  4. A timeout means unknown. Resolve the original operation instead of assuming rejection.
  5. Compensation can fail too. Keep recovery durable, observable and unfinished until its obligations are resolved.

Report a mistake

Tell us what’s wrong in this lesson and we’ll fix it.