Add the transactions module, and move the migration project under migration-behavior/
The repository now aggregates two independent modules. migration-behavior/ is the
original project, moved unchanged; it stays on Spring Boot 4.0.6 / JDK 21 because
that is what the four published migration articles were verified against, and
upgrading it would silently invalidate output they quote. The article-tagged trees
are untouched, so links into a tag are unaffected.
transactions/ Companion code for "@Transactional in Spring: Propagation, Isolation,
and the Six Ways It Silently Does Nothing". Spring Boot 4.1.1 / JDK 25.
Every row of the propagation matrix is produced by calling the method and asking the
transaction manager what it did. The transaction NAME is the exhibit: a scope that
joined reports its caller's name, a scope that started its own reports its own.
Three things the transcripts settle:
- Propagation.NESTED cannot be used with JpaTransactionManager. It fails twice,
with two different messages, the second of which blames your JPA provider. The
savepoint manager comes from the object the JpaDialect returns when it begins the
transaction, and Hibernate's does not implement one. It works on
DataSourceTransactionManager, because a savepoint is a JDBC concept -- shown
working there rather than only failing here.
- Catching a REQUIRED inner failure does not save the transaction. The inner scope
has already marked it rollback-only, so the commit throws
UnexpectedRollbackException from a place with no connection to the cause.
- A checked exception commits, and so does a swallowed one. Those two do not merely
fail to start a transaction; they commit work the code was abandoning.
19 contract tests, six captured transcripts, all regenerated by scripts/run-all.sh.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gip4srpzMwjgoba6uEfbr5
This commit is contained in:
8
transactions/docs/output/00-versions.txt
Normal file
8
transactions/docs/output/00-versions.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
== versions ==
|
||||
openjdk version "25.0.4.1" 2026-08-18 LTS
|
||||
OpenJDK Runtime Environment Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS)
|
||||
OpenJDK 64-Bit Server VM Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS, mixed mode, sharing)
|
||||
|
||||
spring-boot-starter-parent: 4.1.1
|
||||
database: H2 in-memory
|
||||
transaction manager: JpaTransactionManager (Spring Boot default for JPA)
|
||||
80
transactions/docs/output/01-propagation.txt
Normal file
80
transactions/docs/output/01-propagation.txt
Normal file
@@ -0,0 +1,80 @@
|
||||
== the propagation matrix ==
|
||||
|
||||
Each inner method is called twice: once from a @Transactional caller and once from a
|
||||
plain one. 'active' is TransactionSynchronizationManager.isActualTransactionActive();
|
||||
'name' is the transaction's name, which is how you tell JOINING from STARTING -- a
|
||||
joining method reports the OUTER method's name.
|
||||
|
||||
PROPAGATION CALLER ACTIVE TRANSACTION NAME / OUTCOME
|
||||
----------------------------------------------------------------------------
|
||||
REQUIRED inside @Transactional True inTransaction
|
||||
REQUIRED no transaction True required
|
||||
|
||||
REQUIRES_NEW inside @Transactional True requiresNew
|
||||
REQUIRES_NEW no transaction True requiresNew
|
||||
|
||||
NESTED inside @Transactional -- NestedTransactionNotSupportedException
|
||||
NESTED no transaction True nested
|
||||
|
||||
SUPPORTS inside @Transactional True inTransaction
|
||||
SUPPORTS no transaction False supports
|
||||
|
||||
NOT_SUPPORTED inside @Transactional False notSupported
|
||||
NOT_SUPPORTED no transaction False notSupported
|
||||
|
||||
MANDATORY inside @Transactional True inTransaction
|
||||
MANDATORY no transaction -- IllegalTransactionStateException
|
||||
|
||||
NEVER inside @Transactional -- IllegalTransactionStateException
|
||||
NEVER no transaction False never
|
||||
|
||||
|
||||
Reading it:
|
||||
REQUIRED inside a transaction the inner name is the OUTER method -- it joined.
|
||||
REQUIRES_NEW the inner name is its own method -- it started a second transaction.
|
||||
NESTED fails outright on JpaTransactionManager. See docs/output/03-nested.txt.
|
||||
SUPPORTS joins if there is one, runs with none if there is not. No transaction
|
||||
is created, so the write below it lands on an auto-commit connection.
|
||||
NOT_SUPPORTED suspends the outer transaction: active=False even inside one.
|
||||
MANDATORY requires a caller's transaction; IllegalTransactionStateException if none.
|
||||
NEVER requires the absence of one; IllegalTransactionStateException if present.
|
||||
|
||||
== the exact exception messages ==
|
||||
NESTED (withOuterTransaction)
|
||||
org.springframework.transaction.NestedTransactionNotSupportedException
|
||||
Transaction manager does not allow nested transactions by default - specify 'nestedTransactionAllowed' property with value 'true'
|
||||
|
||||
MANDATORY (withoutOuterTransaction)
|
||||
org.springframework.transaction.IllegalTransactionStateException
|
||||
No existing transaction found for transaction marked with propagation 'mandatory'
|
||||
|
||||
NEVER (withOuterTransaction)
|
||||
org.springframework.transaction.IllegalTransactionStateException
|
||||
Existing transaction found for transaction marked with propagation 'never'
|
||||
|
||||
== what the transaction manager logged while doing it ==
|
||||
|
||||
o.s.orm.jpa.JpaTransactionManager: Creating new transaction with name [com.ankurm.tx.service.SilentlyNonTransactional.properlyCalled]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
|
||||
o.s.orm.jpa.JpaTransactionManager: Initiating transaction commit
|
||||
o.s.orm.jpa.JpaTransactionManager: Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.deleteAll]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
|
||||
o.s.orm.jpa.JpaTransactionManager: Initiating transaction commit
|
||||
o.s.orm.jpa.JpaTransactionManager: Creating new transaction with name [com.ankurm.tx.service.SilentlyNonTransactional.checkedExceptionCommits]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
|
||||
o.s.orm.jpa.JpaTransactionManager: Participating in existing transaction
|
||||
o.s.orm.jpa.JpaTransactionManager: Initiating transaction commit
|
||||
o.s.orm.jpa.JpaTransactionManager: Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.existsById]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
|
||||
o.s.orm.jpa.JpaTransactionManager: Initiating transaction commit
|
||||
o.s.orm.jpa.JpaTransactionManager: Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.existsById]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
|
||||
o.s.orm.jpa.JpaTransactionManager: Initiating transaction commit
|
||||
o.s.orm.jpa.JpaTransactionManager: Creating new transaction with name [com.ankurm.tx.service.SilentlyNonTransactional.swallowsException]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
|
||||
o.s.orm.jpa.JpaTransactionManager: Participating in existing transaction
|
||||
o.s.orm.jpa.JpaTransactionManager: Initiating transaction commit
|
||||
o.s.orm.jpa.JpaTransactionManager: Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.existsById]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
|
||||
o.s.orm.jpa.JpaTransactionManager: Initiating transaction commit
|
||||
o.s.orm.jpa.JpaTransactionManager: Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.existsById]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
|
||||
o.s.orm.jpa.JpaTransactionManager: Initiating transaction commit
|
||||
o.s.orm.jpa.JpaTransactionManager: Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.deleteAll]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
|
||||
o.s.orm.jpa.JpaTransactionManager: Initiating transaction commit
|
||||
o.s.orm.jpa.JpaTransactionManager: Creating new transaction with name [com.ankurm.tx.service.OuterService.inTransaction]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
|
||||
o.s.orm.jpa.JpaTransactionManager: Participating in existing transaction
|
||||
o.s.orm.jpa.JpaTransactionManager: Participating in existing transaction
|
||||
o.s.orm.jpa.JpaTransactionManager: Participating in existing transaction
|
||||
40
transactions/docs/output/02-rollback.txt
Normal file
40
transactions/docs/output/02-rollback.txt
Normal file
@@ -0,0 +1,40 @@
|
||||
== rollback behaviour ==
|
||||
|
||||
REQUIRED inner, outer rolls back
|
||||
outcome : IllegalStateException
|
||||
message : outer failed after the inner call returned
|
||||
rows : 0 -> inner work rolled back
|
||||
|
||||
REQUIRES_NEW inner, outer rolls back
|
||||
outcome : IllegalStateException
|
||||
message : outer failed after the inner call returned
|
||||
rows : 1 -> inner work SURVIVED
|
||||
|
||||
NESTED inner, outer rolls back
|
||||
outcome : NestedTransactionNotSupportedException
|
||||
message : Transaction manager does not allow nested transactions by default - specify 'nestedTransactionAllowed' property with value 'true'
|
||||
rows : 0 -> inner work rolled back
|
||||
|
||||
REQUIRED inner throws, outer catches it
|
||||
outcome : UnexpectedRollbackException
|
||||
message : Transaction silently rolled back because it has been marked as rollback-only
|
||||
rows : 0 -> inner work rolled back
|
||||
|
||||
REQUIRES_NEW inner throws, outer catches it
|
||||
outcome : returned normally
|
||||
rows : 0 -> inner work rolled back
|
||||
|
||||
NESTED inner throws, outer catches it
|
||||
outcome : returned normally
|
||||
rows : 0 -> inner work rolled back
|
||||
|
||||
The third and fourth rows are the ones worth sitting with.
|
||||
|
||||
When a REQUIRED inner scope throws, it marks the SHARED transaction rollback-only
|
||||
before the exception leaves it. The caller can catch the exception -- and does, and
|
||||
returns normally -- but the transaction is already doomed, so the commit at the end
|
||||
throws UnexpectedRollbackException. Catching the exception did not save the work; it
|
||||
only moved the failure to a place with no useful stack trace.
|
||||
|
||||
With REQUIRES_NEW the inner scope had its own physical transaction, so its rollback
|
||||
is contained and the caller's catch behaves the way the code reads.
|
||||
37
transactions/docs/output/03-nested.txt
Normal file
37
transactions/docs/output/03-nested.txt
Normal file
@@ -0,0 +1,37 @@
|
||||
== attempt 1: a stock Spring Boot JPA application ==
|
||||
$ java -jar target/transactions-1.0.0.jar
|
||||
|
||||
org.springframework.transaction.NestedTransactionNotSupportedException
|
||||
Transaction manager does not allow nested transactions by default - specify 'nestedTransactionAllowed' property with value 'true'
|
||||
|
||||
== attempt 2: nestedTransactionAllowed = true, as the message instructs ==
|
||||
$ java -jar target/transactions-1.0.0.jar --demo.nested-allowed=true
|
||||
|
||||
org.springframework.transaction.NestedTransactionNotSupportedException
|
||||
JpaDialect does not support savepoints - check your JPA provider's capabilities
|
||||
|
||||
A different message, from a second check. The savepoint manager is obtained from the
|
||||
object the JpaDialect returns when it begins the transaction, and Hibernate's does not
|
||||
implement one -- so no amount of configuration gets NESTED working here.
|
||||
|
||||
== attempt 3: the same propagation on a JDBC transaction manager ==
|
||||
$ curl -s localhost:8081/tx/nested-jdbc
|
||||
|
||||
{
|
||||
"rowsVisibleInsideNestedScope": 2,
|
||||
"nestedScopeThrew": "nested scope fails",
|
||||
"rowsAfterNestedRollback": 1,
|
||||
"rowsAfterOuterCommit": 1,
|
||||
"surviving": [
|
||||
"outer-row"
|
||||
],
|
||||
"transactionManager": "DataSourceTransactionManager (not JpaTransactionManager)"
|
||||
}
|
||||
|
||||
This is what NESTED is for: the nested scope rolled back to its savepoint, the outer
|
||||
transaction carried on and committed, and one of the two rows survived.
|
||||
|
||||
A savepoint is a JDBC concept. DataSourceTransactionManager holds the JDBC connection
|
||||
and can issue one; JpaTransactionManager holds an EntityManager and cannot. The
|
||||
reference documentation does say NESTED works with JDBC resource transactions -- what
|
||||
it does not say is that the JPA path fails, twice, with two different messages.
|
||||
42
transactions/docs/output/04-silent-failures.txt
Normal file
42
transactions/docs/output/04-silent-failures.txt
Normal file
@@ -0,0 +1,42 @@
|
||||
== six pieces of code carrying @Transactional that are not transactional ==
|
||||
|
||||
Row 0 is the control: the SAME annotated method, reached through the proxy.
|
||||
|
||||
{
|
||||
"0-control-through-the-proxy": "through the proxy: actualTransactionActive=true",
|
||||
"1-self-invocation": "self-invocation: actualTransactionActive=false",
|
||||
"2-private-method": "private method: actualTransactionActive=false",
|
||||
"3-checked-exception": {
|
||||
"threw": "Exception",
|
||||
"rowSurvived": true,
|
||||
"verdict": "COMMITTED despite the exception"
|
||||
},
|
||||
"4-swallowed-exception": {
|
||||
"rowSurvived": true,
|
||||
"verdict": "COMMITTED -- the exception never reached the interceptor"
|
||||
},
|
||||
"5-called-from-post-construct": {
|
||||
"transactionActiveDuringPostConstruct": false
|
||||
},
|
||||
"6-created-with-new": "created with new: actualTransactionActive=false"
|
||||
}
|
||||
|
||||
Reading it:
|
||||
|
||||
0 control actualTransactionActive=true. The mechanism works.
|
||||
1 self-invocation entryPoint() is not annotated and calls this.annotated...(),
|
||||
so the proxy is never involved. Same class, same annotation,
|
||||
no transaction.
|
||||
2 private method a CGLIB proxy advises by overriding, and private methods
|
||||
cannot be overridden. Legal Java, no effect.
|
||||
3 checked exception the default rollback rule is RuntimeException or Error. A
|
||||
checked exception propagates AND the transaction commits.
|
||||
Fix: @Transactional(rollbackFor = Exception.class).
|
||||
4 swallowed nothing propagates, so the interceptor sees a normal return
|
||||
and commits. The write survives the failure it 'handled'.
|
||||
5 @PostConstruct the proxy does not exist yet during initialisation.
|
||||
6 new no container, no proxy, no transaction.
|
||||
|
||||
Note what rows 3 and 4 have in common: the row is still there afterwards. These two
|
||||
do not merely fail to start a transaction -- they start one and COMMIT work that the
|
||||
code was trying to abandon.
|
||||
37
transactions/docs/output/05-isolation.txt
Normal file
37
transactions/docs/output/05-isolation.txt
Normal file
@@ -0,0 +1,37 @@
|
||||
== isolation and readOnly ==
|
||||
|
||||
@Transactional(readOnly = true) active=True readOnly=True isolation=default (from the connection)
|
||||
|
||||
@Transactional(isolation = SERIALIZABLE) [outer] active=True readOnly=False isolation=SERIALIZABLE
|
||||
REQUIRED inner joining it active=True readOnly=False isolation=SERIALIZABLE
|
||||
|
||||
plain @Transactional [outer] active=True readOnly=False isolation=default (from the connection)
|
||||
inner declaring READ_UNCOMMITTED active=True readOnly=False isolation=default (from the connection)
|
||||
|
||||
The last pair is the point. The inner method declares
|
||||
@Transactional(isolation = READ_UNCOMMITTED) and gets ISOLATION_DEFAULT, because it
|
||||
joined an existing physical transaction whose isolation was fixed when it began.
|
||||
The declaration is not rejected and nothing is logged -- it is simply ignored.
|
||||
|
||||
Set validateExistingTransaction=true on the transaction manager and this becomes an
|
||||
exception instead of a silent no-op. It is off by default.
|
||||
|
||||
The same applies to readOnly and timeout on a participating scope.
|
||||
|
||||
== the transaction manager's own log lines ==
|
||||
|
||||
o.s.orm.jpa.JpaTransactionManager: Creating new transaction with name [com.ankurm.tx.service.SilentlyNonTransactional.properlyCalled]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
|
||||
o.s.orm.jpa.JpaTransactionManager: Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.deleteAll]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
|
||||
o.s.orm.jpa.JpaTransactionManager: Creating new transaction with name [com.ankurm.tx.service.SilentlyNonTransactional.checkedExceptionCommits]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
|
||||
o.s.orm.jpa.JpaTransactionManager: Participating in existing transaction
|
||||
o.s.orm.jpa.JpaTransactionManager: Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.existsById]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
|
||||
o.s.orm.jpa.JpaTransactionManager: Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.existsById]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
|
||||
o.s.orm.jpa.JpaTransactionManager: Creating new transaction with name [com.ankurm.tx.service.SilentlyNonTransactional.swallowsException]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
|
||||
o.s.orm.jpa.JpaTransactionManager: Participating in existing transaction
|
||||
o.s.orm.jpa.JpaTransactionManager: Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.existsById]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
|
||||
o.s.orm.jpa.JpaTransactionManager: Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.existsById]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
|
||||
o.s.orm.jpa.JpaTransactionManager: Creating new transaction with name [com.ankurm.tx.service.OuterService.readOnlyScope]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
|
||||
o.s.orm.jpa.JpaTransactionManager: Participating in existing transaction
|
||||
|
||||
Note ISOLATION_SERIALIZABLE appears on the 'Creating new transaction' line and never
|
||||
on a 'Participating' one: participation carries no settings of its own.
|
||||
Reference in New Issue
Block a user