The full picture
| Anomaly | Concretely | Countermeasure |
|---|---|---|
| Lost update | saga B overwrites the row saga A is mid-way through | semantic lock (status = PENDING), or optimistic version check |
| Dirty read | reporting counts a charge that gets refunded a second later | expose the pending state (PENDING/CONFIRMED), let readers filter |
| Non-repeatable read | step 4 re-reads what step 1 read and sees a different value | re-read and re-validate before the irreversible step |
| Compensation visible | user sees the charge, then the refund | make it a product decision — say 'authorized', not 'charged' |
- The semantic lock is the workhorse and it is just a status column:
PENDINGmarks 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 - 50commutes with another delta, so two sagas interleaving is a non-event;balance = 250does 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 → SHIPPEDis 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.
// 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));
}