DEV Community

Nazrin Suleymanli
Nazrin Suleymanli

Posted on

The Rollback-Only Trap: When @Transactional Rolls Back Your Whole Batch

How one bad row rolled back a ten-row batch every two seconds — and what Spring's rollback-only flag really means.

A background worker that had run quietly for months started throwing the same exception every two seconds. The catch block was fine. The retry logic was fine. Yet every row in the batch kept reverting, forever. The culprit was a Spring transaction mechanism that is easy to use without ever noticing: the rollback-only flag. This is the story of that incident, and the two misconceptions it cleared up.

The short version: one @Transactional method processed a batch of rows in a single shared transaction. When one row's nested transactional call threw, Spring marked the whole transaction rollback-only — and catching the exception couldn't undo it. So all ten rows reverted, every two seconds, forever. The fix is one transaction per row.

The symptom

Every two seconds, the same line appeared in the log:

UnexpectedRollbackException: Transaction silently rolled back
Enter fullscreen mode Exit fullscreen mode

The worker polls a batch of pending rows from an outbox table and pushes each one to an external process engine over HTTP. It had fallen into a loop: the same ten rows came back every tick, none ever finished, and none ever gave up. Worse, the external HTTP calls were repeated on every tick — so a "start process" call could create duplicate instances for the same person.

Nothing in the catch block looked broken. The code caught its exceptions and recorded failures. Yet the whole batch kept reverting.

The whole bug is a question of transaction scope: wrap the batch in one transaction and a single failure takes everything; wrap each row on its own and a failure stays contained.

The setup: one transaction for the whole batch

The worker ran on a schedule and processed ten rows at a time. The method was annotated like this:

@Transactional(propagation = Propagation.REQUIRES_NEW)   // ONE transaction for all 10 rows
public void processNext() {
    List<ProcessCenterOutbox> rows = outboxRepository.findNextPendingBatch(); // locks 10 rows

    for (ProcessCenterOutbox row : rows) {
        row.setStatus(OutboxStatus.PROCESSING);
        outboxRepository.save(row);
        try {
            dispatch(row);                 // calls the engine, and @Transactional services
            row.setStatus(OutboxStatus.DONE);
        } catch (Exception e) {
            handleFailure(row, e);         // retryCount++, set PENDING or FAILED
        }
        outboxRepository.save(row);
    }
}   // commit happens here, for all 10 rows at once
Enter fullscreen mode Exit fullscreen mode

The subtle part is the propagation. REQUIRES_NEW sounds like "a fresh transaction," and it is — but only one, for the whole method. All ten rows share it. The loop's try/catch looks like it isolates each row, but a catch is not a transaction boundary. One commit covers everything.

Why it broke: the rollback-only flag

One row — a USER_TYPE delete — called a nested service to look up an external ID mapping that didn't exist. That service threw an IllegalStateException. Here is the chain:

  1. The nested service is itself @Transactional with the default propagation (REQUIRED), so it joined the batch's transaction instead of starting its own.
  2. When an exception escapes a @Transactional method, Spring marks the current transaction rollback-only: this transaction may no longer commit, only roll back.
  3. Because the service had joined the shared transaction, the flag landed on the whole batch, not just that one row.
  4. The loop's catch caught the exception and called handleFailure. But nothing can clear the rollback-only flag once it is set; there is no API for it, by design.
  5. At the end of the method Spring tried to commit, saw the flag, rolled back instead, and threw UnexpectedRollbackException.

The rollback undid all ten rows: every DONE status, and — the reason for the loop — every retryCount++. Because the retry count reset to zero each tick, no row ever reached the failure limit. Two seconds later the same ten rows were picked up again.

One thing the rollback did not undo: the HTTP calls. A database rollback can't recall a network request. So every tick repeated them, and a successful "start process" call could create a duplicate instance.

The trap in one line: catching an exception does not save the transaction. If a joined @Transactional method threw, the transaction is already doomed, and the commit fails no matter what your catch does.

The rules behind it: what rolls back, and when a catch helps

Two separate questions hide inside "will it roll back?". Keeping them apart is what the incident taught.

Which exceptions trigger a rollback? By default, Spring rolls back on unchecked exceptions (RuntimeException, Error, and their subclasses) and commits on checked exceptions. You can override this per method:

@Transactional(rollbackFor = Exception.class)             // roll back on checked too
@Transactional(noRollbackFor = ValidationException.class) // don't roll back on this one
Enter fullscreen mode Exit fullscreen mode

rollbackFor adds exceptions to the rollback set; noRollbackFor removes them. The failure here was an IllegalStateException — unchecked — so it rolled back by default. That part was expected.

Does catching the exception let the work continue? This is the one that surprises people, and it depends on where the exception came from, not where it's caught.

Where the exception is thrown Does catching it outside let you continue?
Your own code, inside the same method Yes — no transactional boundary was crossed, nothing is marked
A joined @Transactional bean (REQUIRED) No — the bean's boundary marks the shared transaction rollback-only before your catch runs

So catching works until the exception has passed through an inner @Transactional proxy that joined your transaction. At that boundary Spring decides the transaction can no longer be trusted and marks it rollback-only; the commit then fails with UnexpectedRollbackException regardless of your catch, and regardless of rollbackFor.

One nuance worth knowing: if the caller is not transactional and the inner method is the only transaction, the inner method starts its own transaction, that one rolls back cleanly when the exception escapes, and the caller can catch and continue. The UnexpectedRollbackException only appears when there's an outer transaction the inner one joined.

The fix, part 1: one transaction per row

If one bad row shouldn't poison the other nine, each row needs its own transaction. REQUIRES_NEW on the batch method didn't do that — it opened one transaction for the whole loop. So the fix drops the annotation and opens a transaction per row explicitly, with TransactionTemplate:

public void processNext() {                       // no @Transactional here
    for (Long id : outboxRepository.findNextPendingIds()) {  // just ids, no locks
        processRow(id);
    }
}

private void processRow(Long id) {
    TransactionTemplate tx = new TransactionTemplate(transactionManager);
    tx.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
    AtomicReference<ProcessCenterOutbox> processed = new AtomicReference<>();
    try {
        tx.executeWithoutResult(status ->          // ONE transaction, this row only
            outboxRepository.lockPending(id).ifPresent(row -> {
                processed.set(row);
                process(row);                      // the old loop body, unchanged
            }));
    } catch (RuntimeException e) {
        // ... see part 2
    }
}
Enter fullscreen mode Exit fullscreen mode

Now a rollback only affects the row that caused it; the other nine commit normally.

The AtomicReference here is not about threads. executeWithoutResult takes a lambda, and a lambda can only read effectively-final variables from its scope — it can't assign one back out. AtomicReference is a small mutable box: the lambda puts the row into it with processed.set(row), and the catch block reads it back out with processed.get(). It's the plumbing that carries the row out of the transaction lambda.

The fix, part 2: make the outcome survive the rollback

Per-row transactions alone aren't enough. The failing row still rolls back its own retryCount++, so it would loop on itself forever. The catch handles that:

} catch (RuntimeException e) {
    ProcessCenterOutbox row = processed.get();
    if (row == null) {                          // failed before we even read the row
        log.error("Outbox row {} could not be read", id, e);
        return;
    }
    if (row.getStatus() == OutboxStatus.DONE) { // looked successful, but its work was undone
        handleFailure(row, e);                  // count it as a failed attempt
    }
    tx.executeWithoutResult(status ->           // a second, clean transaction
        outboxRepository.findById(id).ifPresent(stored -> {
            stored.setStatus(row.getStatus());         // PENDING or FAILED
            stored.setRetryCount(row.getRetryCount()); // the increment survives now
            stored.setErrorMessage(row.getErrorMessage());
            outboxRepository.save(stored);
        }));
}
Enter fullscreen mode Exit fullscreen mode

The key detail: a rollback reverts the database, not the in-memory Java object. After the first transaction rolls back, the row object still holds the status and retry count that handleFailure set. A second, fresh transaction writes those values back. Now the increment sticks, and a row that keeps failing reaches FAILED after five attempts and stops. This is the same shape as a REQUIRES_NEW audit log: the main work was undone, but the record of the attempt must persist.

There was also a root-cause fix. The delete only threw because it called a method that throws when a mapping is missing. Returning an Optional instead removes the exception entirely:

Optional<String> externalId = externalIdMappingService.findExternalId(...);
if (externalId.isEmpty()) {
    log.warn("No mapping for USER_TYPE id={}, nothing to delete", id);
    row.setStatus(OutboxStatus.DONE);           // never reached the engine; nothing to delete
    return;
}
Enter fullscreen mode Exit fullscreen mode

The locking changed to match. Instead of locking ten rows up front, the worker now fetches ten ids with no lock, then locks one row at a time inside its own transaction with FOR UPDATE SKIP LOCKED. A row already taken by another instance is simply skipped, so several instances stay safe.

The lessons

  • Catching an exception doesn't rescue the transaction. If a @Transactional method threw, the transaction is marked rollback-only at the proxy boundary — before your catch runs — and the commit fails with UnexpectedRollbackException. You can't clear the flag.
  • Propagation is per call, not per item. REQUIRES_NEW on a batch method gives the batch one transaction, not one per row. To isolate each item, open a transaction per item — an annotation on a per-item method called across a bean boundary, or TransactionTemplate.
  • A rollback reverts the database, not your objects. The in-memory entity keeps whatever you set on it. That's what lets a second transaction re-record the outcome so a retry count survives.
  • Prevent beats isolate. Isolating each row stopped the batch from dying, but the real bug was an exception thrown for an expected case — a missing mapping. Returning Optional so it never throws is the cleaner fix; isolation is the safety net.
  • A rollback can't recall a network call. The HTTP calls weren't undone. The transaction still wraps the call, so a row that rolls back after the engine accepted it can retry and duplicate — now at most five times instead of forever. The durable fix is idempotency: make the external call safe to repeat, with an idempotency key or a natural unique constraint.

Each of these is one line to say and a day to debug. Writing them down is how the day pays off twice.

Sources

Top comments (0)