UnexpectedRollbackException and Spring Transaction Management


(6 comments)
April 12th 2019


I recently ran into an issue that surprised me with Spring and it's transaction management. The setup was roughly:



So the question is, of the two inserts, how many successfully go through? And what happens from the perspective of SomeClient?

The answer is nothing is committed, and an UnexpectedRollbackException is thrown back to SomeClient. If this was immediately obvious, then good for you, and continue merrily on your way. If not, read on...

The first thing to note is that the default propagation for @Transactional is REQUIRED, which means that both ServiceA and ServiceB operate within the same transactional context - i.e. it's either all or nothing with respect to commits and rollbacks.

Second, any unchecked exception that passes through any transactional boundary (e.g. @Transactional annotation) lets Spring know to mark the transaction as rollback. So, in the example above, even if exiting normally from the outer transactional boundary, Spring will, rather surreptitiously, throw the UnexpectedRollbackException to let SomeClient know that the transaction was rolled back. And this is what caught me by surprise.

At first blush, I thought that catching the RuntimeException in ServiceA and not rethrowing it through the outer transaction boundary would somehow absolve the original exception, and all would be committed. Not so...and this of course makes sense when thinking about it. If I wanted to let Spring know that this it's ok for ServiceB to throw a RuntimeException, I could set:


    @Transaction(noRollbackFor = RuntimeException.class)
    public void doSomething() {
        someDAO.insert(); // 1
 
        throw new RuntimeException();
    }

Alternatively, if I wanted ServiceB to be rolled back due to the exception, but not have that interfere with ServiceA's insert, then I could set ServiceB's propagation:



    @Transaction(propagation = REQUIRES_NEW)
    public void doSomething() {
        someDAO.insert(); // 1
 
        throw new RuntimeException();
    }

This would commit ServiceA's insert but not ServiceB's. There is also the helpful propagationBehavior called PROPAGATION_NESTED that can be set on Spring's TransactionDefinition (though note that it's not guaranteed to be supported). A great explanation from Juergen Hoeller:

PROPAGATION_NESTED is different again in that it uses a single physical transaction with multiple savepoints that it can roll back to. Such partial rollbacks allow an inner transaction scope to trigger a rollback for its scope, with the outer transaction being able to continue the physical transaction despite some operations having been rolled back. This is typically mapped onto JDBC savepoints, so will only work with JDBC resource transactions (Spring's DataSourceTransactionManager).

And there are of course more knobs and dials to Spring's Transaction Management (e.g. isolation, rollbackFor, etc.) that are important to understand before you start writing any non-trivially complex transactional code.

In the end, in terms of best practices, I'd assert that it might be a better design to implement a facade layer above the services which serves as the definitive transactional boundary, instead of just marking every Service with @Transactional and then trying to think through every exception thrown and caught between Services. This is especially true if there are quite a few services, and complicated dependencies between them. If some method definitely needs to be committed independent of the outer transaction however, then that particular method can be explicitly marked as REQUIRES_NEW. Otherwise, keep the transaction annotations on this facade and out of the service-level code, and thereby only trigger a rollback if something is thrown through this outer layer.

I'm not sure if this post will ever get seen, but I'd be interested in anyone's thoughts on best practices or something I'm missing (it's very possible!).

I believe that software development is fundamentally about making decisions, and so this is what I write about (mostly). In 2018 I started Highline Solutions, a consulting practice that helps companies with architecture, devops, and full-stack development. I have two degrees from Carnegie Mellon University, one in Information and Decision Systems and one in Philosophy (thesis). I live in Pittsburgh, PA with my wife and 3 energetic boys.
Got a Comment?

Comments (6)
J.F
April 16, 2019
Hi,

I recently stumbled at the exact same problem.
But in my case I cannot use a facade pattern.
Do you have any idea if there is way to tell Spring that the outer transaction has to be committed in any way?
Ben
April 16, 2019
Hi J.F. - If you need ServiceA's insert to be committed, but not ServiceB's, then they should probably be two separate transactions, and so use REQUIRES_NEW on the inner transaction.

Alternatively, you probably could programmatically commit within ServiceA if you wanted, but this would commit everything within that transactional context (i.e. both inserts).
Maciek
July 08, 2020
> I'm not sure if this post will ever get seen

It was seen by me, and I'm thankfull for it. Thanks :)
Konstantin
November 10, 2020
Hi,

Your post is exactly what I was looking for because it discusses the reasoning behind this seemingly confusing behavior.

What I don't understand is why doesn't Spring look at the transaction properties on exiting the outmost @Transactional method (i.e. ServiceA.doSomething()), see that it was marked for rollback, and just roll it back without exceptions?
December 11, 2020
Konstantin,
I think Spring throwing an exception makes sense, or else how would it indicate to the rest of the program that the transaction was rolled back?
If it just allowed the program to continue, it would feel like everything went well.
Ben
January 22, 2021
@Konstantin - good question. I'm not sure, to be honest. I'm guessing that because the exception was caught before propagating out of the ServiceA, Spring "knows" you didn't intend to Rollback. Seems like a framework design decision - but agreed that it's surprising.