Affects: 6.1.13
Transaction synchronization mechanism works incorrectly when used with nested (in terms of invocation) transactions controlled by multiple transaction managers. Assume having service like this:
class ServiceImpl(
private val eventPublisher: ApplicationEventPublisher
) {
private fun doWork(name: String, block: () -> Unit) {
eventPublisher.publishEvent(name)
block()
}
@Transactional("txm1")
fun useTxManager1(name: String, block: () -> Unit) {
doWork(name, block)
}
@Transactional("txm2")
fun useTxManager2(name: String, block: () -> Unit) {
doWork(name, block)
}
}
Then attempt to run «nested» transactions like this
@TransactionalEventListener(phase = AFTER_COMMIT)
fun afterCommitListener(e: Any) {
events.add("after-commit:$e")
}
@TransactionalEventListener(phase = AFTER_ROLLBACK)
fun afterRollbackListener(e: Any) {
events.add("after-rollback:$e")
}
fun test() {
serviceImpl.useTxManager1("txm1-outer") {
serviceImpl.useTxManager2("txm2") {
serviceImpl.useTxManager1("txm1-inner") { }
}
throw NullPointerException()
}
}
leads to an invalid sequence of events:
after-commit:txm2
after-commit:txm1-inner
after-rollback:txm1-outer
Event published inside txm1-inner
scope gets caught by after-commit
listener despite whole transaction is rolled back. Transaction mechanism itself works correctly, I've tested it using real tx managers implementations with H2 as datasource. This issue is related to the synchronization mechanism only.
I took a look at the corresponding source code and believe the main reason for such behavior is that synchronization is not suspended when participating in existing transaction. I'm not sure what proper fix should be, but it's clear that participating in existing transaction does not mean current synchronization should be used because in «nested» scenario synchronization may be left from enclosing transaction managed by another transaction manager.
Full problem reproducer may be found in the file attached to this issue. Just run mvn test
to run all tests. Take a look at the syncId
value printed during test to find relation between tx scope and synchronization in use. Feel free to ask if more details are required.
spring-inconsistent-tx-synchronization-reproducer.zip
Comment From: fufler
Problem seems to be more severe than I've initially thought. With current implementation it's impossible to perform consequent rollbacks. The following test
@Test
fun `two rollbacks should be successfully executed`() {
val status1 = txm1.getTransaction(null)
val status2 = txm2.getTransaction(null)
txm1.rollback(status1)
txm2.rollback(status2)
}
fails with the execption:
java.lang.IllegalStateException: Transaction synchronization is not active
at org.springframework.transaction.support.TransactionSynchronizationManager.getSynchronizations(TransactionSynchronizationManager.java:292)
at org.springframework.transaction.support.AbstractPlatformTransactionManager.triggerAfterCompletion(AbstractPlatformTransactionManager.java:1017)
at org.springframework.transaction.support.AbstractPlatformTransactionManager.processRollback(AbstractPlatformTransactionManager.java:924)
at org.springframework.transaction.support.AbstractPlatformTransactionManager.rollback(AbstractPlatformTransactionManager.java:866)
at com.example.demo.Tests.two rollbacks should be successfully executed(Tests.kt:125)
at java.base/java.lang.reflect.Method.invoke(Method.java:580)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1597)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1597)
This issue seems to be caused by the same problem with synchronization because rollback in different order works just fine:
txm2.rollback(status2)
txm1.rollback(status1)
Comment From: jhoeller
With mixed transaction managers, this outcome is within expectations: Published events get synchronized with the immediate actual transaction boundary around it, so txm1-inner
is synchronized with the useTxManager2
invocation, not with the nested useTxManager1
invocation which happens to just participate in the outer useTxManager1
transaction (not creating an actual transaction itself). Transaction synchronization is thread-bound, intended for a single transaction manager to be in charge of it.
Also, transactions need to be committed/rolled-back in the order of nesting (as it happens with @Transactional
). You may be able to call rollback
on a transaction manager out of order if the transactions do not interact with each other, but in your scenario they are interacting in terms of transaction synchronization which is why an IllegalStateException out of that isn't surprising.
Generally, try to avoid transaction nesting with multiple transaction managers involved. Multiple transaction managers are meant to be used side by side, not in a nested fashion within the same thread invocation context. When such mixed nesting has to happen and transaction synchronization is only necessary for one of the transaction managers, consider turning off transaction synchronization on all other transaction manager configurations (setTransactionSynchronization(SYNCHRONIZATION_NEVER)
).