Interview Prep Zoneby CuriouserLabs

Question 4 of 5 · architect level

A saga gives you A, C and D but no I. Which anomalies does that create, and how do you contain them?

🎤 Say this first

Because every step commits independently, a saga's intermediate state is visible to everyone — so concurrent transactions can read and act on a half-finished one. The three anomalies to name: lost updates (another saga overwrites data your saga wrote and will later compensate), dirty reads (someone reads a pending order or a charge that gets refunded and makes a decision on it), and fuzzy/non-repeatable reads (a saga reads the same row twice across steps and gets different answers). The countermeasures are all application-level, since there's no database to do it for you: semantic locks (a PENDING status flag that says 'a saga owns this'), commutative updates (deltas instead of absolute values, so order stops mattering), pessimistic ordering (put the risky step where it does the least damage), re-read and validate before acting, and version files (record operations and reorder them). Pick per-field, not per-system.

The full picture

AnomalyConcretelyCountermeasure
Lost updatesaga B overwrites the row saga A is mid-way throughsemantic lock (status = PENDING), or optimistic version check
Dirty readreporting counts a charge that gets refunded a second laterexpose the pending state (PENDING/CONFIRMED), let readers filter
Non-repeatable readstep 4 re-reads what step 1 read and sees a different valuere-read and re-validate before the irreversible step
Compensation visibleuser sees the charge, then the refundmake it a product decision — say 'authorized', not 'charged'
  • The semantic lock is the workhorse and it is just a status column: PENDING marks the record as owned by an in-flight saga, and every other path either refuses to touch it or queues. What makes it a real design rather than a flag is answering the operational question: who clears it if the saga never finishes? A semantic lock with no timeout and no sweeper is a distributed deadlock waiting to happen — that follow-up is where this question usually goes.
  • Commutative updates dissolve the problem instead of guarding it: balance = balance - 50 commutes with another delta, so two sagas interleaving is a non-event; balance = 250 does not, and whoever writes last wins. This is the same 'model the domain so conflicts can't exist' move as CRDTs in the consistency topic — the cheapest isolation is the isolation you didn't need.
  • Reservations beat both for limited resources: don't decrement stock, create a reservation row with a TTL. Concurrency is now an insert with a uniqueness or capacity constraint the database enforces, compensation is deleting a row, and an abandoned saga cleans up by expiry rather than by a sweeper you have to write. Same idea as TCC — Try reserves, Confirm settles, Cancel releases.
  • Make the intermediate state part of the domain language, not a leak: PENDING → CONFIRMED → SHIPPED is a state machine the business already understands, and once it's in the model the UI, the reports and the API can all be honest about it. Teams get into trouble by treating saga intermediates as an implementation detail nobody should see — users see them anyway, just without an explanation.
  • Know when to stop and buy real isolation. If the invariant is 'never oversell the last unit' or 'this ledger must balance at every instant', countermeasures are patches on the wrong abstraction. Move that invariant into a single writer: one service, one database, one transaction, one partition that owns it. The strongest architect answer here is recognizing which invariants are not negotiable and redrawing a service boundary rather than defending a saga.
Semantic lock with the part everyone forgets
// Claim the row for this saga — atomically, as a conditional update.
int claimed = jdbc.update("""
    UPDATE orders SET status = 'PENDING', saga_id = ?, locked_until = ?
    WHERE id = ? AND (status <> 'PENDING' OR locked_until < now())
    """, sagaId, now.plusMinutes(5), orderId);

if (claimed == 0) throw new ConcurrentSagaException(orderId);
// Zero rows means another saga holds it. Fail fast and let the caller retry —
// do NOT wait, or you have rebuilt 2PC's blocking with none of its guarantees.

// THE PART TEAMS SKIP: the lock has a deadline, so something must sweep it.
// Without this, one crashed saga freezes an order forever and the only
// remediation is a human running an UPDATE in production at 3am.
@Scheduled(fixedDelay = 60_000)
void reclaimAbandoned() {
    sagaRepo.findExpired().forEach(saga -> orchestrator.resumeOrCompensate(saga));
}

🔄 Likely follow-up questions

  • Who releases a semantic lock when the saga's orchestrator crashes?
  • Why does a reservation with a TTL beat decrementing a counter?
  • Which invariants would make you redraw a service boundary rather than write a saga?