1
0

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:
2026-09-08 16:36:32 +00:00
parent 879a06929a
commit 585eed4d57
53 changed files with 2278 additions and 68 deletions

View File

@@ -0,0 +1,70 @@
[Index](../README.md) &middot; [Propagation &rarr;](02-propagation.md)
# 1. What `@Transactional` actually does
`@Transactional` is an AOP proxy. That single fact predicts every failure in
[chapter 5](05-six-silent-failures.md), and it is the same mechanism as
[Spring AOP](../../../spring-boot-demo/spring-aop) generally.
When a method carrying the annotation is called **through the proxy**, an interceptor:
1. asks the `PlatformTransactionManager` for a transaction, according to the propagation rule;
2. invokes the target method;
3. on a normal return, commits;
4. on a `RuntimeException` or `Error`, rolls back;
5. on a checked `Exception`, **commits** and rethrows.
Step 5 is not a typo. See [chapter 5](05-six-silent-failures.md).
## Logical versus physical
The distinction that makes propagation comprehensible:
- A **logical transaction** is one `@Transactional` scope — one annotated method call.
- A **physical transaction** is one real database transaction with one connection, one
`BEGIN` and one `COMMIT`.
`REQUIRED` maps many logical scopes onto one physical transaction. `REQUIRES_NEW` gives each
scope its own. Everything else is a variation on that theme.
## Seeing which is which
`TransactionSynchronizationManager` is public API and answers this anywhere in application
code:
```java
TransactionSynchronizationManager.isActualTransactionActive(); // is there a PHYSICAL one?
TransactionSynchronizationManager.getCurrentTransactionName(); // whose scope started it?
TransactionSynchronizationManager.isCurrentTransactionReadOnly();
TransactionSynchronizationManager.getCurrentTransactionIsolationLevel();
```
The transaction *name* is the most useful and least known of these. A method that joined its
caller's transaction reports the **caller's** name; a method that started its own reports its
own. That one string distinguishes joining from starting without reading any documentation.
## The log to turn on
```yaml
logging.level.org.springframework.orm.jpa.JpaTransactionManager: DEBUG
logging.level.org.springframework.transaction.interceptor: TRACE
```
which narrates the whole thing in the transaction manager's own words:
```
Creating new transaction with name [...OuterService.serializableScope]:
PROPAGATION_REQUIRED,ISOLATION_SERIALIZABLE
Participating in existing transaction
Initiating transaction commit
```
`Creating new transaction` versus `Participating in existing transaction` is the answer to most
questions people ask about propagation.
## Method visibility
`@Transactional` works on `public` methods, and since Spring 6.0 also on `protected` and
package-private ones **when the proxy is class-based** (which is Spring Boot's default). It
never works on `private` methods, and with interface-based proxies the method must be public
and declared on the interface.

View File

@@ -0,0 +1,66 @@
[&larr; What @Transactional does](01-what-transactional-does.md) &middot; [Index](../README.md) &middot; [Rollback &rarr;](03-rollback.md)
# 2. The seven propagation values
Transcript: [`01-propagation.txt`](output/01-propagation.txt). Every row was produced by calling
the method, not by reading the enum.
| Propagation | Caller has a transaction | Caller has none |
|---|---|---|
| `REQUIRED` (default) | joins it | starts one |
| `REQUIRES_NEW` | suspends it, starts its own | starts one |
| `NESTED` | savepoint — **fails on JPA**, see [chapter 4](04-nested-and-jpa.md) | starts one |
| `SUPPORTS` | joins it | runs with **no** transaction |
| `NOT_SUPPORTED` | **suspends** it, runs with none | runs with none |
| `MANDATORY` | joins it | `IllegalTransactionStateException` |
| `NEVER` | `IllegalTransactionStateException` | runs with none |
## The measured version
```
PROPAGATION CALLER ACTIVE TRANSACTION NAME / OUTCOME
----------------------------------------------------------------------------
REQUIRED inside @Transactional True inTransaction
REQUIRED no transaction True required
REQUIRES_NEW inside @Transactional True requiresNew
NESTED inside @Transactional -- NestedTransactionNotSupportedException
SUPPORTS inside @Transactional True inTransaction
SUPPORTS no transaction False supports
NOT_SUPPORTED inside @Transactional False notSupported
MANDATORY no transaction -- IllegalTransactionStateException
NEVER inside @Transactional -- IllegalTransactionStateException
```
Read the **name** column. `REQUIRED` inside a transaction reports `inTransaction` — the
caller's method — because it joined. `REQUIRES_NEW` reports `requiresNew` — its own — because
it started a second physical transaction.
## Notes that matter in practice
**`SUPPORTS` with no transaction is not "no writes".** The method still runs and still writes;
the write just lands on an auto-commit connection with no rollback available. `SUPPORTS` is for
read paths that do not care, and it is a poor default for anything that mutates.
**`NOT_SUPPORTED` suspends, it does not merely decline.** The caller's transaction is set aside
and restored afterwards. Suspension holds the outer connection open while the inner work runs.
**`REQUIRES_NEW` needs two connections at once.** The outer transaction keeps its connection
while the inner one takes another. A pool sized to the number of request threads will deadlock
under load — size it to exceed concurrent threads by at least one, per request nesting level.
**`MANDATORY` is an assertion.** Use it on a helper that must never be called outside a
transaction; it turns a silent correctness bug into a startup-visible exception.
**`NEVER` is rare** and usually means the work should be somewhere else entirely.
## Exact messages
```
NESTED NestedTransactionNotSupportedException:
Transaction manager does not allow nested transactions by default -
specify 'nestedTransactionAllowed' property with value 'true'
MANDATORY IllegalTransactionStateException:
No existing transaction found for transaction marked with propagation 'mandatory'
NEVER IllegalTransactionStateException:
Existing transaction found for transaction marked with propagation 'never'
```

View File

@@ -0,0 +1,88 @@
[&larr; Propagation](02-propagation.md) &middot; [Index](../README.md) &middot; [NESTED and JPA &rarr;](04-nested-and-jpa.md)
# 3. Rollback, and the exception that appears from nowhere
Transcript: [`02-rollback.txt`](output/02-rollback.txt).
## Does the inner work survive the caller's rollback?
```
REQUIRED inner, outer rolls back rows: 0 inner work rolled back
REQUIRES_NEW inner, outer rolls back rows: 1 inner work SURVIVED
```
That is the entire reason `REQUIRES_NEW` exists. Audit rows, outbox entries and "we tried and
it failed" records need to survive the failure that produced them, and only an independent
physical transaction can do that.
## `UnexpectedRollbackException`
The one worth understanding before it happens at 3am:
```
REQUIRED inner throws, outer catches it
outcome : UnexpectedRollbackException
message : Transaction silently rolled back because it has been marked as rollback-only
rows : 0
```
The sequence:
1. Outer method starts a transaction and calls an inner `REQUIRED` method.
2. The inner method throws. Its interceptor sees a `RuntimeException` — but it is *participating*
in the caller's transaction, so it cannot roll back on its own. It sets **rollback-only** on
the shared transaction and rethrows.
3. The outer method catches the exception and returns normally, believing it handled the
failure.
4. The outer interceptor tries to commit. The transaction is marked rollback-only, so it rolls
back and throws `UnexpectedRollbackException`.
Catching the exception did not save the work. It moved the failure to the commit boundary,
where the stack trace has nothing to do with the original cause.
With `REQUIRES_NEW`, the same code behaves the way it reads: the inner transaction rolled back
independently and the caller's catch worked.
**If you must catch and continue, the inner call has to be `REQUIRES_NEW`.** No amount of
exception handling in the caller fixes a `REQUIRED` inner scope, because the damage is a flag
set on a transaction the caller shares.
## What triggers a rollback
By default: `RuntimeException` and `Error`. **Not** checked exceptions.
```java
@Transactional(rollbackFor = Exception.class) // roll back on checked too
@Transactional(noRollbackFor = NotFoundException.class) // and the reverse
```
The default comes from EJB and surprises almost everyone the first time. A method that declares
`throws IOException` and throws it will **commit** — see [chapter 5](05-six-silent-failures.md),
failure 3.
## Changing the default globally, new in Spring 7
`@EnableTransactionManagement` gained a `rollbackOn()` attribute taking a new `RollbackOn` enum:
```java
@EnableTransactionManagement(rollbackOn = RollbackOn.ALL_EXCEPTIONS)
```
Two constants: `RUNTIME_EXCEPTIONS` (the historical behaviour) and `ALL_EXCEPTIONS`.
Note where it lives. Disassembling `org.springframework.transaction.annotation.Transactional`
in spring-tx 7.0.9 shows twelve attributes and no `rollbackOn` among them, so this is a
configuration-level switch rather than a per-method one. Per-method control is still
`rollbackFor` / `noRollbackFor`.
If checked exceptions committing has bitten you before, this is the switch to flip once rather
than the annotation to remember on every method.
## Marking rollback-only yourself
```java
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
```
Honest when you want to abandon the transaction without throwing — but the caller then gets
`UnexpectedRollbackException` at commit, so make sure that is what you want.

View File

@@ -0,0 +1,83 @@
[&larr; Rollback](03-rollback.md) &middot; [Index](../README.md) &middot; [Six silent failures &rarr;](05-six-silent-failures.md)
# 4. `NESTED`, and why it does not work with JPA
Transcript: [`03-nested.txt`](output/03-nested.txt).
`NESTED` is described everywhere as "uses savepoints, so the inner scope can roll back without
taking the outer transaction with it". That description is accurate and, on a stock Spring Boot
JPA application, unreachable.
## Attempt 1 — a normal Spring Boot JPA application
```
NestedTransactionNotSupportedException:
Transaction manager does not allow nested transactions by default -
specify 'nestedTransactionAllowed' property with value 'true'
```
A clear message naming the fix. So:
## Attempt 2 — do what the message says
```java
JpaTransactionManager manager = new JpaTransactionManager(factory);
manager.setNestedTransactionAllowed(true);
```
```
NestedTransactionNotSupportedException:
JpaDialect does not support savepoints - check your JPA provider's capabilities
```
A *different* message, from a second check, pointing at your JPA provider rather than at your
configuration.
It is not the provider. `JpaTransactionManager` obtains the savepoint manager from the object
the `JpaDialect` returns when it begins the transaction, and Hibernate's does not implement one.
Verified by disassembling `JpaTransactionManager$JpaTransactionObject`, where the second check
is `getEntityManagerHolder().getSavepointManager() == null`. No configuration clears it.
There is a trap inside the trap: constructing `JpaTransactionManager` by hand also discards the
`JpaDialect` Spring Boot would have supplied from the Hibernate vendor adapter, leaving the
no-op `DefaultJpaDialect`. That produces the same second message for a *different* reason, and
sends you looking at your database instead of at your `@Bean`.
## Attempt 3 — the same propagation on a JDBC transaction manager
```json
{
"rowsVisibleInsideNestedScope": 2,
"nestedScopeThrew": "nested scope fails",
"rowsAfterNestedRollback": 1,
"rowsAfterOuterCommit": 1,
"surviving": ["outer-row"],
"transactionManager": "DataSourceTransactionManager (not JpaTransactionManager)"
}
```
Two rows visible inside the nested scope; one after it rolls back; one after the outer
transaction commits — and the survivor is the outer row. That is savepoint semantics working
exactly as advertised.
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, the second of
which blames your database.
## What to do instead
**Use `REQUIRES_NEW`.** It solves most of what people reach for `NESTED` to solve — "let this
part fail without losing everything" — at the cost of a second connection and independent
commit semantics.
The genuine difference: `REQUIRES_NEW` commits the inner work even if the outer transaction
later fails, while `NESTED` would have discarded it. If you need "roll back this part, keep the
rest, and still lose everything if the outer transaction fails", you need savepoints and
therefore JDBC.
Mixing two transaction managers over one `DataSource`, as
[`JdbcNestedService`](../src/main/java/com/ankurm/tx/service/JdbcNestedService.java) does, is a
demonstration and not a recommendation.

View File

@@ -0,0 +1,95 @@
[&larr; NESTED and JPA](04-nested-and-jpa.md) &middot; [Index](../README.md) &middot; [Isolation &rarr;](06-isolation-and-readonly.md)
# 5. Six ways `@Transactional` silently does nothing
Sources: [`SilentlyNonTransactional`](../src/main/java/com/ankurm/tx/service/SilentlyNonTransactional.java),
[`NotABean`](../src/main/java/com/ankurm/tx/service/NotABean.java).
Transcript: [`04-silent-failures.txt`](output/04-silent-failures.txt).
Row 0 is the control: the same annotated method reached through the proxy reports
`actualTransactionActive=true`. The mechanism works. These six do not use it.
## 1. Self-invocation
```java
public String entryPoint() {
return annotatedButCalledInternally(); // this. -> no proxy -> no transaction
}
@Transactional
public String annotatedButCalledInternally() { ... }
```
`actualTransactionActive=false`. The proxy wraps the *object*, not its methods; a call the
object makes to itself never leaves it.
**Fix:** move the method to another bean. Self-injection and `AopContext.currentProxy()` both
work and are both worse.
## 2. A private method
`@Transactional` on a private method is legal Java and inert: a CGLIB proxy advises by
overriding, and private methods cannot be overridden. IntelliJ warns; the compiler does not.
## 3. A checked exception commits
```java
@Transactional
public void checkedExceptionCommits(String id) throws Exception {
accounts.save(new Account(id, 999));
throw new Exception("checked -- this does NOT trigger rollback");
}
```
```
"3-checked-exception": { "threw": "Exception", "rowSurvived": true,
"verdict": "COMMITTED despite the exception" }
```
The default rollback rule is `RuntimeException` or `Error`. A checked exception propagates to
the caller **and the transaction commits on the way out**.
**Fix:** `@Transactional(rollbackFor = Exception.class)`.
## 4. Swallowing the exception
```java
@Transactional
public void swallowsException(String id) {
accounts.save(new Account(id, 555));
try { throw new IllegalStateException("something went wrong"); }
catch (RuntimeException ex) { /* handled */ }
}
```
Nothing propagates, so the interceptor sees a normal return and commits. The write survives the
failure the code appeared to handle.
Failures 3 and 4 are the dangerous pair: they do not merely fail to start a transaction, they
**commit work the code was trying to abandon**.
## 5. Called from `@PostConstruct`
```
"5-called-from-post-construct": { "transactionActiveDuringPostConstruct": false }
```
The proxy does not exist while the bean is still initialising, so there is nothing to intercept
the call. The reference documentation says not to rely on it; this measures what actually
happens.
**Fix:** `ApplicationReadyEvent` or `InitializingBean` on a *different* bean.
## 6. An object created with `new`
No container, no proxy, no transaction. Survives code review easily because the annotation is
right there on the method.
## The one-line check
```java
TransactionSynchronizationManager.isActualTransactionActive()
```
Drop it into the method you believe is transactional. If it prints `false`, you have one of
these six and no amount of reasoning about propagation will help.

View File

@@ -0,0 +1,62 @@
[&larr; Six silent failures](05-six-silent-failures.md) &middot; [Index](../README.md)
# 6. Isolation, read-only, and settings that are ignored
Transcript: [`05-isolation.txt`](output/05-isolation.txt).
## The levels
| Level | Prevents | Cost |
|---|---|---|
| `READ_UNCOMMITTED` | nothing | lowest |
| `READ_COMMITTED` | dirty reads | low — the default on Postgres, SQL Server, Oracle |
| `REPEATABLE_READ` | dirty + non-repeatable reads | medium — the default on MySQL |
| `SERIALIZABLE` | all of the above + phantoms | highest |
`ISOLATION_DEFAULT` means "whatever the connection already has", which is the database's
default and not something Spring chooses.
## Settings apply only where the transaction starts
The finding worth taking away:
```
@Transactional(isolation = SERIALIZABLE) [outer] isolation=SERIALIZABLE
REQUIRED inner joining it isolation=SERIALIZABLE
plain @Transactional [outer] isolation=default
inner declaring READ_UNCOMMITTED isolation=default
```
The inner method declares `@Transactional(isolation = READ_UNCOMMITTED)` and gets the default.
It joined an existing physical transaction, whose isolation was fixed when it began. The
declaration is not rejected, nothing is logged, and it simply has no effect.
The same is true of `readOnly` and `timeout` on a participating scope. All three are properties
of a physical transaction, and a participating scope does not have one of its own.
Confirmed in the transaction manager's own log: `ISOLATION_SERIALIZABLE` appears on
`Creating new transaction` lines and never on `Participating in existing transaction` lines,
because participation carries no settings.
**Make it loud:** set `validateExistingTransaction=true` on the transaction manager and a
mismatched declaration becomes an exception instead of a silent no-op. It is off by default.
## `readOnly` does less than its name suggests
`readOnly = true` is a **hint**. It does not make the database reject writes. What it does:
- Sets the JDBC connection read-only flag, which some drivers act on and others ignore.
- Puts Hibernate's `FlushMode` to `MANUAL`, so the persistence context does not dirty-check or
flush — which is where the real performance benefit comes from on read paths.
- Lets a routing datasource send the transaction to a replica.
A write inside a `readOnly` transaction may silently do nothing (never flushed) or may fail,
depending on the driver. "Silently does nothing" is the more common and more dangerous outcome,
and it is a genuine seventh entry for [chapter 5](05-six-silent-failures.md)'s list.
## `timeout`
Seconds, enforced by Spring for the operations it controls and passed to the JDBC statement
timeout where supported. Applies only to a scope that starts a transaction — same rule as
above.

View 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)

View 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

View 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.

View 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.

View 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.

View 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.