skip to content

Walk through exactly what can go wrong with ChainedTransactionManager during commit. Why is it non-atomic, and which resource is at risk?

level: seniorimportance: should knowfreq 40%

answer

  1. commit reverse order, sequential local commits
  2. A commits, B fails → A orphaned
  3. danger only in commit phase, not business logic
  4. earliest-committed resource is at risk
  5. fix: ordering, idempotency, outbox, JTA/XA

basics

~20 s

It commits each manager one at a time. If an earlier one commits and a later commit fails, the earlier commit is already durable and cannot be undone, so you get a partially-committed, inconsistent state.

solid answer

~40 s

ChainedTransactionManager commits delegates sequentially, in reverse of registration order, with no prepare phase. Suppose managers commit as [A then B]. If A commits successfully but B's commit throws, A is already durably committed and there is nothing that can roll it back — B failed, A stuck committed: partial failure. Spring propagates the exception, but the damage is done. The window is narrow (only failures *during* the commit phase, not business-logic failures, which roll everything back cleanly), but real: a broker outage, network blip, or constraint violation at commit time. The resource committed *earliest* is the one left orphaned if a *later* one fails. That's why it's called best-effort 1PC — it tries, but cannot guarantee all-or-nothing. Mitigations: make the second commit far more reliable, order accordingly, add idempotency/reconciliation, or move to outbox/JTA.

code

java · 12 lines
java
// Registration: (jms, jpa) -> commit order is reverse: jpa first, jms last.
// If jpa commits then jms.commit() throws, the JPA write is orphaned.
new ChainedTransactionManager(jms, jpa);

@Transactional("chainedTxManager")
public void process(Order o) {
    orderRepository.save(o);              // participates in jpa tx
    jmsTemplate.convertAndSend("q", o);   // participates in jms tx
    // commit phase: jpa.commit() ok, jms.commit() fails ->
    //   DB row saved, message never sent, exception thrown to caller.
    //   No rollback of the DB is possible.
}

go deeper

for a junior

Grasp that commits happen one by one and can leave one done, one not.

for a middle

Identify the commit-phase window and that early-committed resources are orphaned.

for a senior

Reason about commit ordering to shrink blast radius and name idempotency/outbox mitigations.

for a principal

Drive toward outbox/saga/JTA and articulate 1PC-vs-2PC coordinator semantics precisely.

**Setup:** `ChainedTransactionManager` holds an ordered list of delegate `PlatformTransactionManager`s. Begin opens a transaction on each. Commit walks the list and calls `commit()` on each delegate **in reverse order**; rollback calls `rollback()` on each. **The safe path — business failure:** If your method throws *before* the commit phase (validation fails, a save violates a constraint during flush, etc.), every delegate is rolled back. Nothing is committed. This case *looks* atomic and is fine. **The dangerous path — commit-phase failure:** The problem is when the delegates are already being committed. Say the effective commit order is A, then B. 1. `A.commit()` succeeds. A's data is now durable and, from A's perspective, the transaction is over — there is no open transaction to roll back. 2. `B.commit()` throws (broker down, deadlock, unique-constraint deferred check, connection reset). 3. ChainedTransactionManager can do nothing to reverse A. It surfaces the exception, but A stays committed and B is not committed. **Partial failure.** **Why non-atomic:** Atomicity across resources requires either a coordinator running two-phase commit (prepare-all, then commit-all — so a failure in prepare aborts everyone) or a single shared transaction. ChainedTransactionManager does neither. It performs N independent local commits (**one-phase** each). Once local commit #1 returns, it is a point of no return for that resource. **Which resource is at risk:** The one that commits **earliest** in the sequence. If a *later* commit fails, everything committed before it is orphaned. Since commit runs in reverse of registration order, the manager you registered **last** commits **first** and is thus most exposed to being orphaned. Practical guidance: register the most reliable / most-recoverable resource so it commits last, and put the resource whose failure you most want to detect early so it commits first. Common advice: let the datastore that is hardest to compensate commit last, and the flakier one (a message broker) commit first — if the broker fails, nothing else has committed yet. **Edge cases:** - A commit that *hangs* (no exception, no return) leaves the chain stuck; timeouts on the underlying resources matter. - Rollback during the chain can itself partially fail, compounding inconsistency. - `@Transactional` propagation still applies to the outer chained boundary; nested calls join the same chained transaction. **Mitigations:** - **Ordering** as above to shrink the blast radius. - **Idempotency + reconciliation:** design consumers so a re-sent message or replayed write is harmless, then reconcile out-of-band. - **Transactional outbox:** commit the message as a row in the *same* DB transaction, publish asynchronously — turns two resources into one. - **JTA/XA:** a real 2PC coordinator when the platform supports it and the cost is justified. **Bottom line for the interview:** ChainedTransactionManager coordinates *boundaries*, not *outcomes*. It reduces the failure window but cannot eliminate partial commits, which is exactly why it's deprecated and why 'best-effort 1PC' is the precise term.

  • If business logic throws before commit, is there any partial-failure risk?
    No. A pre-commit exception rolls back every delegate cleanly; that path is effectively atomic. The risk exists only during the commit sequence itself.
  • How would you redesign to eliminate the partial-commit window entirely?
    Use a transactional outbox: write the intended message as a row in the same DB transaction as the business data, then a separate poller publishes it. Now there's one resource and one commit. Alternatively use JTA/XA for true 2PC.

saying these in an interview costs you the question

  • Saying Spring rolls back the already-committed resource when a later commit fails
  • Claiming the risk exists for any exception, ignoring that pre-commit exceptions roll back cleanly
  • Thinking reordering makes it fully atomic rather than just shrinking the window

context