Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Rollback for doubly nested transaction bypasses savepoint

It's not exactly as the title says, but close to. Consider these Spring beans:

@Bean
class BeanA {

    @Transactional(propagation = Propagation.REQUIRED, rollbackFor = EvilException.class)
    public void methodA() {
        /* ... some actions */
        if (condition) {
            throw new EvilException();
        }
    }
}

@Bean
class BeanB {
    @Autowired private BeanA beanA;
    final int MAX_TRIES = 3;

    @Transactional(propagation = Propagation.NESTED)
    public void methodB() {
        // prepare to call Bean A
        try {
            beanA.methodA();
            /* maybe do some more things */
        }
        catch (EvilException e) {
           /* recover from evil */
        }
    }
}

@Bean
class MainWorkerBean {
    @Autowired private BeanB beanB;
    @Autowired private OtherBean otherBean;

    @Transactional(propagation = Propagation.REQUIRED)
    public void doSomeWork() {
        beanB.methodB();
        otherBean.doSomeWork();
    }
}

Important note: I'm using JDBC transaction manager that supports savepoints.

What I'm expecting this to do is, when EvilException is thrown, the transaction of the BeanA is rolled back, which with this setup happens to be the savepoint created by starting methodB. However, this appears to not be the case.

When going over with debugging tools, what I'm seeing is this:

  1. When doSomeWork of MainWorkerBean starts, new transaction is created
  2. When methodB starts, transaction manager properly initializes a savepoint and hands it to TransactionInterceptor
  3. When methodA starts, transaction manager sees Propagation.REQUIRED again, and hands out a clean reference to the actual JDBC transaction again, that has no knowledge of the savepoint

This means that when exception is thrown, TransactionStatus::hasSavepoint return false, which leads to roll back of the whole global transaction, so recovery and further steps are as good as lost, but my actual code has no knowledge of the rollback (since I've written recovery for it).

For now, I can't consider changing BeanA's transaction to Propagation.NESTED. Admittedly, looks like it's going to allow me to have the more local rollback, but it's going to be too local, because as I understand it, Spring then will have two savepoints, and only roll back the BeanA savepoint, not BeanB one, as I'd like.

Is there anything else I'm missing, such as a configuration option, that would make internal transaction with Propagation.REQUIRED consider that it is running inside a savepoint, and roll back to savepoint, not the whole thing?

Right now we're using Spring 4.3.24, but I already crawled through their code and can't spot any relevant changes, so I don't think upgrading will help me.

like image 694
M. Prokhorov Avatar asked Oct 25 '19 19:10

M. Prokhorov


1 Answers

As described in this bug ticket: https://github.com/spring-projects/spring-framework/issues/11234

For spring versions < 5.0, in the situation described, the global transaction is set to 'rollback-only'.

In this transaction I am processing several tasks. If an error should occur during a single task, I do not want the whole transaction to be rolled back, therefore I wrap the task processing in another transaction boundary with a propagation of PROPAGATION_NESTED.

The problem comes when, during task processing, calls are made to existing service methods defined with a transaction boundary of PROPAGATION_REQUIRED. Any runtime exceptions thrown from these methods cause the underlying connection to be marked as rollback-only, rather than respecting the current parent transaction nested propagation.

[...]

As of Spring Framework 5.0, nested transactions resolve their rollback-only status on a rollback to a savepoint, not applying it to the global transaction anymore.

On older versions, the recommended workaround is to switch globalRollbackOnParticipationFailure to false in such scenarios.

However, even for Spring5, I noticed when reproducing the problem, that the nested transaction may be rolled back, including all things done in the catch block of methodB(). So your recover code might not work inside methodB(), depending on what your recovery looks like. If methodA() was not transactional, that would not happen. Just something to watch out for.

Some more details to be found here: https://github.com/spring-projects/spring-framework/issues/8135

like image 67
tkruse Avatar answered Oct 22 '22 20:10

tkruse