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:
74
transactions/README.md
Normal file
74
transactions/README.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# @Transactional: propagation, isolation, and the six silent failures
|
||||
|
||||
Companion project for [**@Transactional in Spring**](https://ankurm.com/) on ankurm.com.
|
||||
|
||||
Every row of the propagation matrix was produced by calling the method and asking the
|
||||
transaction manager what it did — not by reading the enum.
|
||||
|
||||
## Versions
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Spring Boot | 4.1.1 |
|
||||
| Spring Framework | 7.0.9 |
|
||||
| JDK | Eclipse Temurin 25.0.4.1 (LTS) |
|
||||
| Database | H2 in-memory |
|
||||
| Transaction manager | `JpaTransactionManager` (Spring Boot's default for JPA) |
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
export JAVA_HOME=/path/to/jdk-25
|
||||
mvn -DskipTests package
|
||||
./scripts/run-all.sh # regenerate every transcript in docs/output/
|
||||
mvn test # 19 contract tests
|
||||
```
|
||||
|
||||
The application listens on **8081** so it can run alongside the other demos.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|---|---|
|
||||
| `GET /tx/propagation` | all seven propagations, called with and without a caller's transaction |
|
||||
| `GET /tx/rollback` | does the inner write survive the caller's rollback? |
|
||||
| `GET /tx/silent` | the six failures, plus a control that works |
|
||||
| `GET /tx/nested-jdbc` | `NESTED` succeeding, on a JDBC transaction manager |
|
||||
| `GET /tx/isolation` | isolation and `readOnly` where they apply and where they are ignored |
|
||||
|
||||
## Options
|
||||
|
||||
| Property | Effect |
|
||||
|---|---|
|
||||
| `--demo.nested-allowed=true` | replaces the transaction manager with one that has `nestedTransactionAllowed=true`, to show the *second* failure |
|
||||
|
||||
## Documentation
|
||||
|
||||
1. [What `@Transactional` actually does](docs/01-what-transactional-does.md)
|
||||
2. [The seven propagation values](docs/02-propagation.md)
|
||||
3. [Rollback, and the exception that appears from nowhere](docs/03-rollback.md)
|
||||
4. [`NESTED`, and why it does not work with JPA](docs/04-nested-and-jpa.md)
|
||||
5. [Six ways `@Transactional` silently does nothing](docs/05-six-silent-failures.md)
|
||||
6. [Isolation, read-only, and settings that are ignored](docs/06-isolation-and-readonly.md)
|
||||
|
||||
## Captured output
|
||||
|
||||
| File | Produced by |
|
||||
|---|---|
|
||||
| [`00-versions.txt`](docs/output/00-versions.txt) | `scripts/demo-versions.sh` |
|
||||
| [`01-propagation.txt`](docs/output/01-propagation.txt) | `scripts/demo-propagation.sh` |
|
||||
| [`02-rollback.txt`](docs/output/02-rollback.txt) | `scripts/demo-rollback.sh` |
|
||||
| [`03-nested.txt`](docs/output/03-nested.txt) | `scripts/demo-nested.sh` |
|
||||
| [`04-silent-failures.txt`](docs/output/04-silent-failures.txt) | `scripts/demo-silent.sh` |
|
||||
| [`05-isolation.txt`](docs/output/05-isolation.txt) | `scripts/demo-isolation.sh` |
|
||||
|
||||
## 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. It works on
|
||||
`DataSourceTransactionManager`, because a savepoint is a JDBC concept.
|
||||
- **Catching a `REQUIRED` inner failure does not save the transaction.** The inner scope already
|
||||
marked it rollback-only, so the commit throws `UnexpectedRollbackException` from a place with
|
||||
no connection to the original cause.
|
||||
- **A checked exception commits.** So does a swallowed one. These two do not merely fail to
|
||||
start a transaction — they commit work the code was trying to abandon.
|
||||
70
transactions/docs/01-what-transactional-does.md
Normal file
70
transactions/docs/01-what-transactional-does.md
Normal file
@@ -0,0 +1,70 @@
|
||||
[Index](../README.md) · [Propagation →](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.
|
||||
66
transactions/docs/02-propagation.md
Normal file
66
transactions/docs/02-propagation.md
Normal file
@@ -0,0 +1,66 @@
|
||||
[← What @Transactional does](01-what-transactional-does.md) · [Index](../README.md) · [Rollback →](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'
|
||||
```
|
||||
88
transactions/docs/03-rollback.md
Normal file
88
transactions/docs/03-rollback.md
Normal file
@@ -0,0 +1,88 @@
|
||||
[← Propagation](02-propagation.md) · [Index](../README.md) · [NESTED and JPA →](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.
|
||||
83
transactions/docs/04-nested-and-jpa.md
Normal file
83
transactions/docs/04-nested-and-jpa.md
Normal file
@@ -0,0 +1,83 @@
|
||||
[← Rollback](03-rollback.md) · [Index](../README.md) · [Six silent failures →](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.
|
||||
95
transactions/docs/05-six-silent-failures.md
Normal file
95
transactions/docs/05-six-silent-failures.md
Normal file
@@ -0,0 +1,95 @@
|
||||
[← NESTED and JPA](04-nested-and-jpa.md) · [Index](../README.md) · [Isolation →](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.
|
||||
62
transactions/docs/06-isolation-and-readonly.md
Normal file
62
transactions/docs/06-isolation-and-readonly.md
Normal file
@@ -0,0 +1,62 @@
|
||||
[← Six silent failures](05-six-silent-failures.md) · [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.
|
||||
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.
|
||||
53
transactions/pom.xml
Normal file
53
transactions/pom.xml
Normal file
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>4.1.1</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.ankurm</groupId>
|
||||
<artifactId>transactions</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<name>transactions</name>
|
||||
<description>@Transactional: propagation, isolation, and the ways it silently does nothing</description>
|
||||
|
||||
<properties>
|
||||
<java.version>25</java.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webmvc</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
43
transactions/scripts/demo-isolation.sh
Executable file
43
transactions/scripts/demo-isolation.sh
Executable file
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
# Isolation and read-only: honoured where the transaction starts, ignored where it joins.
|
||||
set -euo pipefail
|
||||
set +m
|
||||
cd "$(dirname "$0")/.."
|
||||
source scripts/env.sh
|
||||
{
|
||||
echo "== isolation and readOnly =="
|
||||
echo
|
||||
start_app > /dev/null
|
||||
curl -s "http://127.0.0.1:${APP_PORT}/tx/isolation" | python3 -c '
|
||||
import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
def show(label, s):
|
||||
print(" %-46s active=%-5s readOnly=%-5s isolation=%s" % (
|
||||
label, s["actualTransactionActive"], s["readOnly"], s["isolationLevel"]))
|
||||
show("@Transactional(readOnly = true)", d["readOnlyScope"])
|
||||
print()
|
||||
show("@Transactional(isolation = SERIALIZABLE) [outer]", d["serializableOuter"]["outer"])
|
||||
show(" REQUIRED inner joining it", d["serializableOuter"]["inner"])
|
||||
print()
|
||||
show("plain @Transactional [outer]", d["participantDeclaringReadUncommitted"]["outer"])
|
||||
show(" inner declaring READ_UNCOMMITTED", d["participantDeclaringReadUncommitted"]["inner"])'
|
||||
echo
|
||||
echo "The last pair is the point. The inner method declares"
|
||||
echo "@Transactional(isolation = READ_UNCOMMITTED) and gets ISOLATION_DEFAULT, because it"
|
||||
echo "joined an existing physical transaction whose isolation was fixed when it began."
|
||||
echo "The declaration is not rejected and nothing is logged -- it is simply ignored."
|
||||
echo
|
||||
echo "Set validateExistingTransaction=true on the transaction manager and this becomes an"
|
||||
echo "exception instead of a silent no-op. It is off by default."
|
||||
echo
|
||||
echo "The same applies to readOnly and timeout on a participating scope."
|
||||
echo
|
||||
echo "== the transaction manager's own log lines =="
|
||||
echo
|
||||
grep -E "Creating new transaction|Participating in existing" "$LOG" | tidy | head -12
|
||||
echo
|
||||
echo "Note ISOLATION_SERIALIZABLE appears on the 'Creating new transaction' line and never"
|
||||
echo "on a 'Participating' one: participation carries no settings of its own."
|
||||
stop_app
|
||||
} > docs/output/05-isolation.txt 2>&1
|
||||
cat docs/output/05-isolation.txt
|
||||
46
transactions/scripts/demo-nested.sh
Executable file
46
transactions/scripts/demo-nested.sh
Executable file
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
# Can you actually use Propagation.NESTED with Spring Data JPA? Three attempts.
|
||||
set -euo pipefail
|
||||
set +m
|
||||
cd "$(dirname "$0")/.."
|
||||
source scripts/env.sh
|
||||
{
|
||||
echo "== attempt 1: a stock Spring Boot JPA application =="
|
||||
echo "\$ java -jar $JAR"
|
||||
echo
|
||||
start_app > /dev/null
|
||||
curl -s "http://127.0.0.1:${APP_PORT}/tx/propagation" | python3 -c '
|
||||
import json,sys
|
||||
v=json.load(sys.stdin)["NESTED"]["withOuterTransaction"]
|
||||
print(" " + v.get("exception","(no exception)"))
|
||||
print(" " + v.get("message",""))'
|
||||
echo
|
||||
echo "== attempt 2: nestedTransactionAllowed = true, as the message instructs =="
|
||||
echo "\$ java -jar $JAR --demo.nested-allowed=true"
|
||||
echo
|
||||
start_app --demo.nested-allowed=true > /dev/null
|
||||
curl -s "http://127.0.0.1:${APP_PORT}/tx/propagation" | python3 -c '
|
||||
import json,sys
|
||||
v=json.load(sys.stdin)["NESTED"]["withOuterTransaction"]
|
||||
print(" " + v.get("exception","(no exception)"))
|
||||
print(" " + v.get("message",""))'
|
||||
echo
|
||||
echo "A different message, from a second check. The savepoint manager is obtained from the"
|
||||
echo "object the JpaDialect returns when it begins the transaction, and Hibernate's does not"
|
||||
echo "implement one -- so no amount of configuration gets NESTED working here."
|
||||
echo
|
||||
echo "== attempt 3: the same propagation on a JDBC transaction manager =="
|
||||
echo "\$ curl -s localhost:$APP_PORT/tx/nested-jdbc"
|
||||
echo
|
||||
curl -s "http://127.0.0.1:${APP_PORT}/tx/nested-jdbc" | python3 -m json.tool | sed 's/^/ /'
|
||||
echo
|
||||
echo "This is what NESTED is for: the nested scope rolled back to its savepoint, the outer"
|
||||
echo "transaction carried on and committed, and one of the two rows survived."
|
||||
echo
|
||||
echo "A savepoint is a JDBC concept. DataSourceTransactionManager holds the JDBC connection"
|
||||
echo "and can issue one; JpaTransactionManager holds an EntityManager and cannot. The"
|
||||
echo "reference documentation does say NESTED works with JDBC resource transactions -- what"
|
||||
echo "it does not say is that the JPA path fails, twice, with two different messages."
|
||||
stop_app
|
||||
} > docs/output/03-nested.txt 2>&1
|
||||
cat docs/output/03-nested.txt
|
||||
60
transactions/scripts/demo-propagation.sh
Executable file
60
transactions/scripts/demo-propagation.sh
Executable file
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
# All seven propagation values, each called with and without an outer transaction.
|
||||
set -euo pipefail
|
||||
set +m
|
||||
cd "$(dirname "$0")/.."
|
||||
source scripts/env.sh
|
||||
{
|
||||
echo "== the propagation matrix =="
|
||||
echo
|
||||
echo "Each inner method is called twice: once from a @Transactional caller and once from a"
|
||||
echo "plain one. 'active' is TransactionSynchronizationManager.isActualTransactionActive();"
|
||||
echo "'name' is the transaction's name, which is how you tell JOINING from STARTING -- a"
|
||||
echo "joining method reports the OUTER method's name."
|
||||
echo
|
||||
start_app > /dev/null
|
||||
curl -s "http://127.0.0.1:${APP_PORT}/tx/propagation" | python3 -c '
|
||||
import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
print(" %-14s %-24s %-8s %s" % ("PROPAGATION","CALLER","ACTIVE","TRANSACTION NAME / OUTCOME"))
|
||||
print(" " + "-"*76)
|
||||
for name,row in d.items():
|
||||
for ctx,label in (("withOuterTransaction","inside @Transactional"),
|
||||
("withoutOuterTransaction","no transaction")):
|
||||
v=row[ctx]
|
||||
if "exception" in v:
|
||||
print(" %-14s %-24s %-8s %s" % (name, label, "--", v["exception"].split(".")[-1]))
|
||||
else:
|
||||
i=v["inner"]
|
||||
print(" %-14s %-24s %-8s %s" % (name, label, i["actualTransactionActive"],
|
||||
str(i["transactionName"]).split(".")[-1]))
|
||||
print()'
|
||||
echo
|
||||
echo "Reading it:"
|
||||
echo " REQUIRED inside a transaction the inner name is the OUTER method -- it joined."
|
||||
echo " REQUIRES_NEW the inner name is its own method -- it started a second transaction."
|
||||
echo " NESTED fails outright on JpaTransactionManager. See docs/output/03-nested.txt."
|
||||
echo " SUPPORTS joins if there is one, runs with none if there is not. No transaction"
|
||||
echo " is created, so the write below it lands on an auto-commit connection."
|
||||
echo " NOT_SUPPORTED suspends the outer transaction: active=False even inside one."
|
||||
echo " MANDATORY requires a caller's transaction; IllegalTransactionStateException if none."
|
||||
echo " NEVER requires the absence of one; IllegalTransactionStateException if present."
|
||||
echo
|
||||
echo "== the exact exception messages =="
|
||||
curl -s "http://127.0.0.1:${APP_PORT}/tx/propagation" | python3 -c '
|
||||
import json,sys
|
||||
for name,row in json.load(sys.stdin).items():
|
||||
for ctx in ("withOuterTransaction","withoutOuterTransaction"):
|
||||
v=row[ctx]
|
||||
if "exception" in v:
|
||||
print(" %s (%s)" % (name, ctx))
|
||||
print(" %s" % v["exception"])
|
||||
print(" %s" % v["message"])
|
||||
print()'
|
||||
echo "== what the transaction manager logged while doing it =="
|
||||
echo
|
||||
grep -E "Creating new transaction|Participating in existing|Suspending current|Initiating transaction|Not creating" "$LOG" \
|
||||
| tidy | head -24
|
||||
stop_app
|
||||
} > docs/output/01-propagation.txt 2>&1
|
||||
cat docs/output/01-propagation.txt
|
||||
32
transactions/scripts/demo-rollback.sh
Executable file
32
transactions/scripts/demo-rollback.sh
Executable file
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
# Does the inner write survive when the outer transaction fails? And what happens when the
|
||||
# caller catches the inner exception?
|
||||
set -euo pipefail
|
||||
set +m
|
||||
cd "$(dirname "$0")/.."
|
||||
source scripts/env.sh
|
||||
{
|
||||
echo "== rollback behaviour =="
|
||||
echo
|
||||
start_app > /dev/null
|
||||
curl -s "http://127.0.0.1:${APP_PORT}/tx/rollback" | python3 -c '
|
||||
import json,sys
|
||||
for k,v in json.load(sys.stdin).items():
|
||||
print(" %s" % k)
|
||||
print(" outcome : %s" % v["outcome"])
|
||||
if "message" in v: print(" message : %s" % v["message"])
|
||||
print(" rows : %s -> %s" % (v["auditRowsSurviving"], v["verdict"]))
|
||||
print()'
|
||||
echo "The third and fourth rows are the ones worth sitting with."
|
||||
echo
|
||||
echo "When a REQUIRED inner scope throws, it marks the SHARED transaction rollback-only"
|
||||
echo "before the exception leaves it. The caller can catch the exception -- and does, and"
|
||||
echo "returns normally -- but the transaction is already doomed, so the commit at the end"
|
||||
echo "throws UnexpectedRollbackException. Catching the exception did not save the work; it"
|
||||
echo "only moved the failure to a place with no useful stack trace."
|
||||
echo
|
||||
echo "With REQUIRES_NEW the inner scope had its own physical transaction, so its rollback"
|
||||
echo "is contained and the caller's catch behaves the way the code reads."
|
||||
stop_app
|
||||
} > docs/output/02-rollback.txt 2>&1
|
||||
cat docs/output/02-rollback.txt
|
||||
36
transactions/scripts/demo-silent.sh
Executable file
36
transactions/scripts/demo-silent.sh
Executable file
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
# The six ways @Transactional silently does nothing.
|
||||
set -euo pipefail
|
||||
set +m
|
||||
cd "$(dirname "$0")/.."
|
||||
source scripts/env.sh
|
||||
{
|
||||
echo "== six pieces of code carrying @Transactional that are not transactional =="
|
||||
echo
|
||||
echo "Row 0 is the control: the SAME annotated method, reached through the proxy."
|
||||
echo
|
||||
start_app > /dev/null
|
||||
curl -s "http://127.0.0.1:${APP_PORT}/tx/silent" | python3 -m json.tool | sed 's/^/ /'
|
||||
echo
|
||||
echo "Reading it:"
|
||||
echo
|
||||
echo " 0 control actualTransactionActive=true. The mechanism works."
|
||||
echo " 1 self-invocation entryPoint() is not annotated and calls this.annotated...(),"
|
||||
echo " so the proxy is never involved. Same class, same annotation,"
|
||||
echo " no transaction."
|
||||
echo " 2 private method a CGLIB proxy advises by overriding, and private methods"
|
||||
echo " cannot be overridden. Legal Java, no effect."
|
||||
echo " 3 checked exception the default rollback rule is RuntimeException or Error. A"
|
||||
echo " checked exception propagates AND the transaction commits."
|
||||
echo " Fix: @Transactional(rollbackFor = Exception.class)."
|
||||
echo " 4 swallowed nothing propagates, so the interceptor sees a normal return"
|
||||
echo " and commits. The write survives the failure it 'handled'."
|
||||
echo " 5 @PostConstruct the proxy does not exist yet during initialisation."
|
||||
echo " 6 new no container, no proxy, no transaction."
|
||||
echo
|
||||
echo "Note what rows 3 and 4 have in common: the row is still there afterwards. These two"
|
||||
echo "do not merely fail to start a transaction -- they start one and COMMIT work that the"
|
||||
echo "code was trying to abandon."
|
||||
stop_app
|
||||
} > docs/output/04-silent-failures.txt 2>&1
|
||||
cat docs/output/04-silent-failures.txt
|
||||
13
transactions/scripts/demo-versions.sh
Executable file
13
transactions/scripts/demo-versions.sh
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
source scripts/env.sh
|
||||
{
|
||||
echo "== versions =="
|
||||
java -version 2>&1 | clean
|
||||
echo
|
||||
echo "spring-boot-starter-parent: $(grep -A2 '<artifactId>spring-boot-starter-parent' pom.xml | grep '<version>' | sed 's/.*<version>\(.*\)<\/version>.*/\1/')"
|
||||
echo "database: H2 in-memory"
|
||||
echo "transaction manager: JpaTransactionManager (Spring Boot default for JPA)"
|
||||
} > docs/output/00-versions.txt 2>&1
|
||||
cat docs/output/00-versions.txt
|
||||
51
transactions/scripts/env.sh
Executable file
51
transactions/scripts/env.sh
Executable file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shared environment. Point JAVA_HOME at a JDK 25 (or newer) installation.
|
||||
: "${JAVA_HOME:?set JAVA_HOME to a JDK 25+ installation}"
|
||||
export PATH="$JAVA_HOME/bin:$PATH"
|
||||
MVN="${MVN:-mvn}"
|
||||
JAR="target/transactions-1.0.0.jar"
|
||||
APP_PORT="${APP_PORT:-8081}"
|
||||
LOG="${LOG:-/tmp/transactions-demo.log}"
|
||||
|
||||
# Strip machine-specific noise from committed transcripts.
|
||||
clean() { grep -v "Picked up JAVA_TOOL_OPTIONS" | grep -v "^OpenJDK 64-Bit Server VM warning"; }
|
||||
|
||||
# Reduce a Spring log line to its message, so transcripts diff cleanly between runs.
|
||||
tidy() { sed -E 's/^[0-9T:.-]+Z +//; s/^[A-Z]+ +[0-9]+ --- \[[^]]*\] \[[^]]*\] +//; s/ +: /: /'; }
|
||||
|
||||
# Start detached and block until it answers. Deliberately NOT setsid: setsid forks when it is
|
||||
# not already a process-group leader, so $! would name a process that exits immediately and
|
||||
# the JVM would survive every later stop_app -- holding the port, so the next scenario fails
|
||||
# to bind and curl answers from the previous one. That reads exactly like the configuration
|
||||
# under test having had no effect.
|
||||
start_app() {
|
||||
stop_app
|
||||
mkdir -p target
|
||||
nohup java -jar "$JAR" "$@" > "$LOG" 2>&1 < /dev/null &
|
||||
echo $! > target/app.pid
|
||||
for _ in $(seq 1 60); do
|
||||
curl -s -o /dev/null "http://127.0.0.1:${APP_PORT}/tx/silent" 2>/dev/null && return 0
|
||||
kill -0 "$(cat target/app.pid)" 2>/dev/null || { echo "JVM exited during startup:"
|
||||
tail -20 "$LOG"; return 1; }
|
||||
sleep 1
|
||||
done
|
||||
echo "application did not answer"; tail -20 "$LOG"; return 1
|
||||
}
|
||||
|
||||
# Stop by recorded PID. Never by pattern: `ps | grep transactions` also matches the shell
|
||||
# running this script, because that string is on its own command line.
|
||||
stop_app() {
|
||||
if [ -f target/app.pid ]; then
|
||||
pid=$(cat target/app.pid)
|
||||
if [ -n "$pid" ] && grep -qa "transactions" "/proc/$pid/cmdline" 2>/dev/null; then
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
wait "$pid" 2>/dev/null || true
|
||||
fi
|
||||
rm -f target/app.pid
|
||||
fi
|
||||
for _ in $(seq 1 40); do
|
||||
if ! (exec 3<>/dev/tcp/127.0.0.1/"${APP_PORT}") 2>/dev/null; then break; fi
|
||||
sleep 0.25
|
||||
done
|
||||
exec 3<&- 2>/dev/null || true
|
||||
}
|
||||
16
transactions/scripts/run-all.sh
Executable file
16
transactions/scripts/run-all.sh
Executable file
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regenerate every transcript under docs/output/.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
source scripts/env.sh
|
||||
|
||||
"$MVN" -B -q package -DskipTests
|
||||
|
||||
for demo in versions propagation rollback nested silent isolation; do
|
||||
echo "=== $demo ==="
|
||||
"scripts/demo-$demo.sh" > /dev/null
|
||||
done
|
||||
stop_app
|
||||
echo
|
||||
echo "regenerated:"
|
||||
ls -1 docs/output/
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.ankurm.tx;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/**
|
||||
* Companion application for the ankurm.com article
|
||||
* "@Transactional in Spring: Propagation, Isolation, and the Six Ways It Silently Does Nothing".
|
||||
*
|
||||
* <p>Every claim in that article is produced by running something here. The propagation
|
||||
* matrix comes from {@code /tx/propagation}, which calls each of the seven propagation values
|
||||
* from inside an outer transaction and reports what the transaction manager actually did; the
|
||||
* failure gallery comes from {@code /tx/silent}, which runs six pieces of code that look
|
||||
* transactional and are not.
|
||||
*/
|
||||
@SpringBootApplication
|
||||
public class TransactionsApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(TransactionsApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.ankurm.tx.config;
|
||||
|
||||
import jakarta.persistence.EntityManagerFactory;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.orm.jpa.JpaTransactionManager;
|
||||
import org.springframework.orm.jpa.vendor.HibernateJpaDialect;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
|
||||
/**
|
||||
* Makes {@code Propagation.NESTED} work.
|
||||
*
|
||||
* <p>Out of the box it does not. {@link JpaTransactionManager} is created with
|
||||
* {@code nestedTransactionAllowed} left at {@code false}, so the first {@code NESTED} call
|
||||
* inside an existing transaction fails with:
|
||||
*
|
||||
* <pre>
|
||||
* NestedTransactionNotSupportedException: Transaction manager does not allow nested
|
||||
* transactions by default - specify 'nestedTransactionAllowed' property with value 'true'
|
||||
* </pre>
|
||||
*
|
||||
* <p>That is worth stating plainly, because {@code NESTED} is routinely described as "uses
|
||||
* savepoints so the inner scope can roll back independently" without mentioning that a
|
||||
* default Spring Boot JPA application cannot use it at all until this flag is flipped.
|
||||
*
|
||||
* <p>Flipping it is not free. Nested transactions are savepoints on one JDBC connection, so
|
||||
* they require a resource-local transaction against a driver that supports savepoints. They
|
||||
* do not work across a JTA transaction manager, and Hibernate's flush ordering means the
|
||||
* savepoint only protects statements that have actually reached the database — a
|
||||
* pending change still sitting in the persistence context is not covered by a rollback to
|
||||
* savepoint until it is flushed.
|
||||
*
|
||||
* <p>Activated by {@code demo.nested-allowed=true}; {@code scripts/demo-nested.sh} runs the
|
||||
* same scenarios with and without it.
|
||||
*/
|
||||
@Configuration
|
||||
@ConditionalOnProperty(name = "demo.nested-allowed", havingValue = "true")
|
||||
public class NestedTransactionConfig {
|
||||
|
||||
@Bean
|
||||
public PlatformTransactionManager transactionManager(EntityManagerFactory factory) {
|
||||
JpaTransactionManager manager = new JpaTransactionManager(factory);
|
||||
// Constructing the manager by hand loses the JpaDialect Spring Boot would have
|
||||
// supplied from the Hibernate vendor adapter, leaving the no-op DefaultJpaDialect.
|
||||
// Miss this and NESTED fails with a DIFFERENT message -- "JpaDialect does not support
|
||||
// savepoints" -- which sends you looking at your database instead of your @Bean.
|
||||
manager.setJpaDialect(new HibernateJpaDialect());
|
||||
manager.setNestedTransactionAllowed(true);
|
||||
return manager;
|
||||
}
|
||||
}
|
||||
34
transactions/src/main/java/com/ankurm/tx/domain/Account.java
Normal file
34
transactions/src/main/java/com/ankurm/tx/domain/Account.java
Normal file
@@ -0,0 +1,34 @@
|
||||
package com.ankurm.tx.domain;
|
||||
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
|
||||
/** Minimal entity. The balance is what every rollback demonstration checks afterwards. */
|
||||
@Entity
|
||||
public class Account {
|
||||
|
||||
@Id
|
||||
private String id;
|
||||
|
||||
private long balance;
|
||||
|
||||
protected Account() {
|
||||
}
|
||||
|
||||
public Account(String id, long balance) {
|
||||
this.id = id;
|
||||
this.balance = balance;
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public long getBalance() {
|
||||
return balance;
|
||||
}
|
||||
|
||||
public void setBalance(long balance) {
|
||||
this.balance = balance;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.ankurm.tx.domain;
|
||||
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.Id;
|
||||
|
||||
/**
|
||||
* Written by the inner transaction in every propagation scenario. Whether a row survives the
|
||||
* outer rollback is the whole question REQUIRES_NEW exists to answer.
|
||||
*/
|
||||
@Entity
|
||||
public class AuditEntry {
|
||||
|
||||
@Id
|
||||
@GeneratedValue
|
||||
private Long id;
|
||||
|
||||
private String note;
|
||||
|
||||
protected AuditEntry() {
|
||||
}
|
||||
|
||||
public AuditEntry(String note) {
|
||||
this.note = note;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getNote() {
|
||||
return note;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.ankurm.tx.repo;
|
||||
|
||||
import com.ankurm.tx.domain.Account;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface AccountRepository extends JpaRepository<Account, String> {
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.ankurm.tx.repo;
|
||||
|
||||
import com.ankurm.tx.domain.AuditEntry;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface AuditRepository extends JpaRepository<AuditEntry, Long> {
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.ankurm.tx.service;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import com.ankurm.tx.domain.AuditEntry;
|
||||
import com.ankurm.tx.repo.AuditRepository;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* One method per propagation value, each writing an audit row and reporting the transaction
|
||||
* state it found itself in.
|
||||
*
|
||||
* <p>Called from {@link OuterService}, which decides whether an outer transaction exists. The
|
||||
* combination of "outer transaction present or absent" and "propagation value" is the entire
|
||||
* propagation table, and running it is more reliable than remembering it.
|
||||
*/
|
||||
@Service
|
||||
public class InnerService {
|
||||
|
||||
private final AuditRepository audit;
|
||||
|
||||
public InnerService(AuditRepository audit) {
|
||||
this.audit = audit;
|
||||
}
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRED)
|
||||
public Map<String, Object> required(String note) {
|
||||
return write("REQUIRED", note);
|
||||
}
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public Map<String, Object> requiresNew(String note) {
|
||||
return write("REQUIRES_NEW", note);
|
||||
}
|
||||
|
||||
@Transactional(propagation = Propagation.NESTED)
|
||||
public Map<String, Object> nested(String note) {
|
||||
return write("NESTED", note);
|
||||
}
|
||||
|
||||
@Transactional(propagation = Propagation.SUPPORTS)
|
||||
public Map<String, Object> supports(String note) {
|
||||
return write("SUPPORTS", note);
|
||||
}
|
||||
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
public Map<String, Object> notSupported(String note) {
|
||||
return write("NOT_SUPPORTED", note);
|
||||
}
|
||||
|
||||
@Transactional(propagation = Propagation.MANDATORY)
|
||||
public Map<String, Object> mandatory(String note) {
|
||||
return write("MANDATORY", note);
|
||||
}
|
||||
|
||||
@Transactional(propagation = Propagation.NEVER)
|
||||
public Map<String, Object> never(String note) {
|
||||
return write("NEVER", note);
|
||||
}
|
||||
|
||||
/** Marks the CURRENT transaction rollback-only and returns normally. */
|
||||
@Transactional(propagation = Propagation.REQUIRED)
|
||||
public void requiredThenFail(String note) {
|
||||
write("REQUIRED (about to throw)", note);
|
||||
throw new IllegalStateException("inner failed");
|
||||
}
|
||||
|
||||
/** Independent transaction that fails: its own work rolls back, the caller's does not. */
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void requiresNewThenFail(String note) {
|
||||
write("REQUIRES_NEW (about to throw)", note);
|
||||
throw new IllegalStateException("inner failed");
|
||||
}
|
||||
|
||||
/** Rolls back to the savepoint only, if the transaction manager supports savepoints. */
|
||||
@Transactional(propagation = Propagation.NESTED)
|
||||
public void nestedThenFail(String note) {
|
||||
write("NESTED (about to throw)", note);
|
||||
throw new IllegalStateException("inner failed");
|
||||
}
|
||||
|
||||
private Map<String, Object> write(String label, String note) {
|
||||
Map<String, Object> state = TxProbe.snapshot("inner:" + label);
|
||||
// SUPPORTS and NOT_SUPPORTED may have no transaction at all. Writing anyway is the
|
||||
// point: the row is what proves whether the write was inside a transaction or not.
|
||||
audit.save(new AuditEntry(note + ":" + label));
|
||||
state.put("auditRowsVisibleFromHere", audit.count());
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.ankurm.tx.service;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
/**
|
||||
* {@code Propagation.NESTED} actually working — which requires leaving JPA behind.
|
||||
*
|
||||
* <p>{@link org.springframework.orm.jpa.JpaTransactionManager} cannot do nested transactions.
|
||||
* Setting {@code nestedTransactionAllowed=true} gets you past the first check and into a
|
||||
* second one, {@code "JpaDialect does not support savepoints"}, which no amount of
|
||||
* configuration clears: the savepoint manager comes from the object the dialect returns when
|
||||
* it begins the transaction, and Hibernate's does not implement one.
|
||||
*
|
||||
* <p>{@link DataSourceTransactionManager} does, because a savepoint is a JDBC concept and it
|
||||
* is holding the JDBC connection directly. This service uses its own transaction manager over
|
||||
* the same {@link DataSource} so the article can show the mechanism succeeding rather than
|
||||
* only failing.
|
||||
*
|
||||
* <p>Mixing two transaction managers over one DataSource in a real application is a way to
|
||||
* lose an afternoon; this is a demonstration, not a recommendation. The honest advice, which
|
||||
* the article gives, is that {@code REQUIRES_NEW} solves most of what people reach for
|
||||
* {@code NESTED} to solve.
|
||||
*/
|
||||
@Service
|
||||
public class JdbcNestedService {
|
||||
|
||||
private final JdbcTemplate jdbc;
|
||||
private final TransactionTemplate outerTx;
|
||||
private final TransactionTemplate nestedTx;
|
||||
|
||||
public JdbcNestedService(DataSource dataSource) {
|
||||
this.jdbc = new JdbcTemplate(dataSource);
|
||||
|
||||
DataSourceTransactionManager manager = new DataSourceTransactionManager(dataSource);
|
||||
manager.setNestedTransactionAllowed(true);
|
||||
|
||||
this.outerTx = new TransactionTemplate(manager);
|
||||
this.nestedTx = new TransactionTemplate(manager);
|
||||
this.nestedTx.setPropagationBehavior(TransactionDefinition.PROPAGATION_NESTED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes one row in the outer transaction and one in a nested scope, rolls the nested
|
||||
* scope back, and commits the outer one. The savepoint means the first row survives and
|
||||
* the second does not — the partial rollback NESTED exists for.
|
||||
*/
|
||||
public Map<String, Object> partialRollback() {
|
||||
jdbc.execute("CREATE TABLE IF NOT EXISTS nested_demo (note VARCHAR(64))");
|
||||
jdbc.update("DELETE FROM nested_demo");
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
|
||||
outerTx.executeWithoutResult(outerStatus -> {
|
||||
jdbc.update("INSERT INTO nested_demo VALUES ('outer-row')");
|
||||
|
||||
try {
|
||||
nestedTx.executeWithoutResult(nestedStatus -> {
|
||||
jdbc.update("INSERT INTO nested_demo VALUES ('nested-row')");
|
||||
result.put("rowsVisibleInsideNestedScope",
|
||||
jdbc.queryForObject("SELECT COUNT(*) FROM nested_demo", Integer.class));
|
||||
throw new IllegalStateException("nested scope fails");
|
||||
});
|
||||
} catch (IllegalStateException ex) {
|
||||
// Caught OUTSIDE the nested scope. With NESTED this is survivable: the
|
||||
// rollback went to the savepoint, not to the start of the outer transaction.
|
||||
result.put("nestedScopeThrew", ex.getMessage());
|
||||
}
|
||||
|
||||
result.put("rowsAfterNestedRollback",
|
||||
jdbc.queryForObject("SELECT COUNT(*) FROM nested_demo", Integer.class));
|
||||
});
|
||||
|
||||
result.put("rowsAfterOuterCommit",
|
||||
jdbc.queryForObject("SELECT COUNT(*) FROM nested_demo", Integer.class));
|
||||
result.put("surviving",
|
||||
jdbc.queryForList("SELECT note FROM nested_demo", String.class));
|
||||
result.put("transactionManager", "DataSourceTransactionManager (not JpaTransactionManager)");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.ankurm.tx.service;
|
||||
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* <strong>6. The object is not a bean.</strong>
|
||||
*
|
||||
* <p>Constructed with {@code new} in a helper, a factory or a test. Spring never saw it, so
|
||||
* there is no proxy and {@code @Transactional} is documentation. This is the failure mode that
|
||||
* survives code review most easily, because the annotation is right there on the method.
|
||||
*/
|
||||
public class NotABean {
|
||||
|
||||
@Transactional
|
||||
public String work() {
|
||||
return "created with new: actualTransactionActive=" + TxProbe.active();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.ankurm.tx.service;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
import com.ankurm.tx.repo.AuditRepository;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Runs a piece of inner work either inside an outer transaction or outside one, so each
|
||||
* propagation value can be observed in both situations.
|
||||
*
|
||||
* <p>The {@code *AndRollback} variants throw after the inner call, which is how the article
|
||||
* answers the question people actually have: <em>does the inner work survive when the outer
|
||||
* transaction fails?</em>
|
||||
*/
|
||||
@Service
|
||||
public class OuterService {
|
||||
|
||||
private final AuditRepository audit;
|
||||
|
||||
public OuterService(AuditRepository audit) {
|
||||
this.audit = audit;
|
||||
}
|
||||
|
||||
/** Calls the inner work with an outer physical transaction in progress. */
|
||||
@Transactional
|
||||
public Map<String, Object> inTransaction(Function<String, Map<String, Object>> inner) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("outer", TxProbe.snapshot("outer (REQUIRED)"));
|
||||
result.put("inner", inner.apply("in-tx"));
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Calls the same inner work with no transaction in progress. */
|
||||
public Map<String, Object> withoutTransaction(Function<String, Map<String, Object>> inner) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("outer", TxProbe.snapshot("outer (no @Transactional)"));
|
||||
result.put("inner", inner.apply("no-tx"));
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the inner work, then throws. Whatever the inner call committed independently
|
||||
* survives; whatever joined the outer transaction does not.
|
||||
*/
|
||||
@Transactional
|
||||
public void inTransactionThenFail(Consumer<String> inner) {
|
||||
inner.accept("outer-fails");
|
||||
throw new IllegalStateException("outer failed after the inner call returned");
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls an inner method that throws, catches the exception, and returns normally.
|
||||
*
|
||||
* <p>With {@code REQUIRED} the inner scope has already marked the shared transaction
|
||||
* rollback-only by the time the exception is caught, so catching it does not save the
|
||||
* transaction — the commit at the end of this method fails with
|
||||
* {@code UnexpectedRollbackException}. This surprises people every time.
|
||||
*/
|
||||
@Transactional
|
||||
public String catchInnerFailure(Consumer<String> inner) {
|
||||
try {
|
||||
inner.accept("caught");
|
||||
} catch (RuntimeException ex) {
|
||||
return "caught " + ex.getClass().getSimpleName() + ", returning normally";
|
||||
}
|
||||
return "inner did not throw";
|
||||
}
|
||||
|
||||
/** Read-only scope, used to show what read-only does and does not prevent. */
|
||||
@Transactional(readOnly = true)
|
||||
public Map<String, Object> readOnlyScope() {
|
||||
Map<String, Object> state = TxProbe.snapshot("outer (readOnly = true)");
|
||||
state.put("auditRows", audit.count());
|
||||
return state;
|
||||
}
|
||||
|
||||
/** Declares an isolation level, which is honoured only when it starts a transaction. */
|
||||
@Transactional(isolation = org.springframework.transaction.annotation.Isolation.SERIALIZABLE)
|
||||
public Map<String, Object> serializableScope(Function<String, Map<String, Object>> inner) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("outer", TxProbe.snapshot("outer (SERIALIZABLE)"));
|
||||
result.put("inner", inner.apply("serializable"));
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* An inner scope that declares its own isolation level while joining an existing
|
||||
* transaction. The declaration is silently ignored, because there is only one physical
|
||||
* transaction and its isolation was fixed when it began.
|
||||
*/
|
||||
@Transactional(propagation = Propagation.REQUIRED,
|
||||
isolation = org.springframework.transaction.annotation.Isolation.READ_UNCOMMITTED)
|
||||
public Map<String, Object> readUncommittedParticipant() {
|
||||
return TxProbe.snapshot("inner (REQUIRED + READ_UNCOMMITTED declared)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.ankurm.tx.service;
|
||||
|
||||
import com.ankurm.tx.domain.Account;
|
||||
import com.ankurm.tx.repo.AccountRepository;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Six pieces of code carrying {@code @Transactional} that are not transactional.
|
||||
*
|
||||
* <p>None of them warn. None of them fail at startup. Each one runs, appears to work, and
|
||||
* leaves the database in a state nobody asked for. They are numbered to match the article's
|
||||
* gallery, and {@code /tx/silent} runs all six and reports
|
||||
* {@code actualTransactionActive} for each.
|
||||
*/
|
||||
@Service
|
||||
public class SilentlyNonTransactional {
|
||||
|
||||
private final AccountRepository accounts;
|
||||
|
||||
/** Recorded during {@link #onStartup()} so the article can show what it saw. */
|
||||
private boolean transactionActiveDuringPostConstruct;
|
||||
|
||||
public SilentlyNonTransactional(AccountRepository accounts) {
|
||||
this.accounts = accounts;
|
||||
}
|
||||
|
||||
/**
|
||||
* <strong>1. Self-invocation.</strong> {@link #entryPoint()} is called through the proxy,
|
||||
* so the interceptor runs for it — but it is not annotated. The call it makes to
|
||||
* {@link #annotatedButCalledInternally()} is a plain {@code this.} call, so the
|
||||
* interceptor never sees it and no transaction is started.
|
||||
*/
|
||||
public String entryPoint() {
|
||||
return annotatedButCalledInternally();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public String annotatedButCalledInternally() {
|
||||
return "self-invocation: actualTransactionActive=" + TxProbe.active();
|
||||
}
|
||||
|
||||
/**
|
||||
* <strong>2. A private method.</strong> A CGLIB proxy advises by overriding, and a private
|
||||
* method cannot be overridden. The annotation is legal Java and has no effect. IntelliJ
|
||||
* warns about this one; the compiler does not.
|
||||
*/
|
||||
public String callsPrivate() {
|
||||
return privateTransactional();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
private String privateTransactional() {
|
||||
return "private method: actualTransactionActive=" + TxProbe.active();
|
||||
}
|
||||
|
||||
/**
|
||||
* <strong>3. A checked exception.</strong> The default rollback rule is
|
||||
* {@code RuntimeException} or {@code Error}. A checked exception propagates out of the
|
||||
* method and the transaction <em>commits</em> on the way, which is the opposite of what
|
||||
* almost everyone expects the first time.
|
||||
*
|
||||
* <p>Fix: {@code @Transactional(rollbackFor = Exception.class)}.
|
||||
*/
|
||||
@Transactional
|
||||
public void checkedExceptionCommits(String id) throws Exception {
|
||||
accounts.save(new Account(id, 999));
|
||||
throw new Exception("checked -- this does NOT trigger rollback");
|
||||
}
|
||||
|
||||
/**
|
||||
* <strong>4. Swallowing the exception.</strong> Catching it inside the transactional
|
||||
* method means nothing propagates, so the interceptor sees a normal return and commits.
|
||||
* The write survives a failure the code appeared to handle.
|
||||
*/
|
||||
@Transactional
|
||||
public void swallowsException(String id) {
|
||||
accounts.save(new Account(id, 555));
|
||||
try {
|
||||
throw new IllegalStateException("something went wrong");
|
||||
} catch (RuntimeException ex) {
|
||||
// Deliberately swallowed. The commit still happens.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* <strong>5. Called from {@code @PostConstruct}.</strong> The proxy is not in place while
|
||||
* the bean is still being initialised, so the annotation on the method being called has
|
||||
* nothing to intercept it. The reference documentation says not to rely on it here; this
|
||||
* records what actually happens.
|
||||
*/
|
||||
@PostConstruct
|
||||
void onStartup() {
|
||||
this.transactionActiveDuringPostConstruct = duringInitialisation();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public boolean duringInitialisation() {
|
||||
return TxProbe.active();
|
||||
}
|
||||
|
||||
public boolean wasTransactionActiveDuringPostConstruct() {
|
||||
return transactionActiveDuringPostConstruct;
|
||||
}
|
||||
|
||||
/** Used by the endpoint to prove the same method IS transactional through the proxy. */
|
||||
@Transactional
|
||||
public String properlyCalled() {
|
||||
return "through the proxy: actualTransactionActive=" + TxProbe.active();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.ankurm.tx.service;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
/**
|
||||
* Reports what the transaction infrastructure believes is happening at the point it is called.
|
||||
*
|
||||
* <p>This is the tool that turns "@Transactional isn't working" from a guess into a
|
||||
* measurement. {@link TransactionSynchronizationManager} is public API and every field below
|
||||
* is available anywhere in application code, which is worth knowing before spending an
|
||||
* afternoon adding log statements.
|
||||
*
|
||||
* <p>The distinction that matters most is {@code actualTransactionActive}: a method can be
|
||||
* inside a {@code @Transactional} scope and still have no physical transaction, which is
|
||||
* exactly what {@code NOT_SUPPORTED} and a missing proxy both look like.
|
||||
*/
|
||||
public final class TxProbe {
|
||||
|
||||
private TxProbe() {
|
||||
}
|
||||
|
||||
public static Map<String, Object> snapshot(String where) {
|
||||
Map<String, Object> state = new LinkedHashMap<>();
|
||||
state.put("where", where);
|
||||
state.put("actualTransactionActive",
|
||||
TransactionSynchronizationManager.isActualTransactionActive());
|
||||
state.put("transactionName",
|
||||
TransactionSynchronizationManager.getCurrentTransactionName());
|
||||
state.put("readOnly",
|
||||
TransactionSynchronizationManager.isCurrentTransactionReadOnly());
|
||||
Integer isolation = TransactionSynchronizationManager.getCurrentTransactionIsolationLevel();
|
||||
state.put("isolationLevel", isolation == null ? "default (from the connection)"
|
||||
: isolationName(isolation));
|
||||
state.put("synchronizationActive",
|
||||
TransactionSynchronizationManager.isSynchronizationActive());
|
||||
return state;
|
||||
}
|
||||
|
||||
/** True when a physical transaction is in progress. The one-line answer. */
|
||||
public static boolean active() {
|
||||
return TransactionSynchronizationManager.isActualTransactionActive();
|
||||
}
|
||||
|
||||
public static String name() {
|
||||
String name = TransactionSynchronizationManager.getCurrentTransactionName();
|
||||
return name == null ? "(none)" : name.substring(name.lastIndexOf('.') + 1);
|
||||
}
|
||||
|
||||
private static String isolationName(int level) {
|
||||
return switch (level) {
|
||||
case 1 -> "READ_UNCOMMITTED";
|
||||
case 2 -> "READ_COMMITTED";
|
||||
case 4 -> "REPEATABLE_READ";
|
||||
case 8 -> "SERIALIZABLE";
|
||||
default -> "level " + level;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package com.ankurm.tx.web;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import com.ankurm.tx.repo.AccountRepository;
|
||||
import com.ankurm.tx.repo.AuditRepository;
|
||||
import com.ankurm.tx.service.InnerService;
|
||||
import com.ankurm.tx.service.JdbcNestedService;
|
||||
import com.ankurm.tx.service.NotABean;
|
||||
import com.ankurm.tx.service.OuterService;
|
||||
import com.ankurm.tx.service.SilentlyNonTransactional;
|
||||
|
||||
import org.springframework.transaction.UnexpectedRollbackException;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** Drives every scenario the article quotes. */
|
||||
@RestController
|
||||
public class TransactionEndpoint {
|
||||
|
||||
private final OuterService outer;
|
||||
private final InnerService inner;
|
||||
private final SilentlyNonTransactional silent;
|
||||
private final JdbcNestedService jdbcNested;
|
||||
private final AccountRepository accounts;
|
||||
private final AuditRepository audit;
|
||||
|
||||
public TransactionEndpoint(OuterService outer, InnerService inner,
|
||||
SilentlyNonTransactional silent, JdbcNestedService jdbcNested,
|
||||
AccountRepository accounts, AuditRepository audit) {
|
||||
this.outer = outer;
|
||||
this.inner = inner;
|
||||
this.silent = silent;
|
||||
this.jdbcNested = jdbcNested;
|
||||
this.accounts = accounts;
|
||||
this.audit = audit;
|
||||
}
|
||||
|
||||
/** Each propagation value, called both inside and outside an outer transaction. */
|
||||
@GetMapping("/tx/propagation")
|
||||
public Map<String, Object> propagation() {
|
||||
audit.deleteAll();
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
|
||||
record Case(String name, java.util.function.Function<String, Map<String, Object>> call) {
|
||||
}
|
||||
var cases = java.util.List.of(
|
||||
new Case("REQUIRED", inner::required),
|
||||
new Case("REQUIRES_NEW", inner::requiresNew),
|
||||
new Case("NESTED", inner::nested),
|
||||
new Case("SUPPORTS", inner::supports),
|
||||
new Case("NOT_SUPPORTED", inner::notSupported),
|
||||
new Case("MANDATORY", inner::mandatory),
|
||||
new Case("NEVER", inner::never));
|
||||
|
||||
for (Case c : cases) {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("withOuterTransaction", attempt(() -> outer.inTransaction(c.call())));
|
||||
row.put("withoutOuterTransaction", attempt(() -> outer.withoutTransaction(c.call())));
|
||||
result.put(c.name(), row);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Does the inner write survive when the outer transaction rolls back? */
|
||||
@GetMapping("/tx/rollback")
|
||||
public Map<String, Object> rollback() {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
|
||||
result.put("REQUIRED inner, outer rolls back",
|
||||
survives(() -> outer.inTransactionThenFail(note -> inner.required(note))));
|
||||
result.put("REQUIRES_NEW inner, outer rolls back",
|
||||
survives(() -> outer.inTransactionThenFail(note -> inner.requiresNew(note))));
|
||||
result.put("NESTED inner, outer rolls back",
|
||||
survives(() -> outer.inTransactionThenFail(note -> inner.nested(note))));
|
||||
|
||||
result.put("REQUIRED inner throws, outer catches it",
|
||||
survives(() -> outer.catchInnerFailure(inner::requiredThenFail)));
|
||||
result.put("REQUIRES_NEW inner throws, outer catches it",
|
||||
survives(() -> outer.catchInnerFailure(inner::requiresNewThenFail)));
|
||||
result.put("NESTED inner throws, outer catches it",
|
||||
survives(() -> outer.catchInnerFailure(inner::nestedThenFail)));
|
||||
return result;
|
||||
}
|
||||
|
||||
/** The six ways it silently does nothing. */
|
||||
@GetMapping("/tx/silent")
|
||||
public Map<String, Object> silent() {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
|
||||
result.put("0-control-through-the-proxy", silent.properlyCalled());
|
||||
result.put("1-self-invocation", silent.entryPoint());
|
||||
result.put("2-private-method", silent.callsPrivate());
|
||||
|
||||
Map<String, Object> checked = new LinkedHashMap<>();
|
||||
accounts.deleteAll();
|
||||
try {
|
||||
silent.checkedExceptionCommits("checked-1");
|
||||
} catch (Exception ex) {
|
||||
checked.put("threw", ex.getClass().getSimpleName());
|
||||
}
|
||||
checked.put("rowSurvived", accounts.existsById("checked-1"));
|
||||
checked.put("verdict", accounts.existsById("checked-1")
|
||||
? "COMMITTED despite the exception" : "rolled back");
|
||||
result.put("3-checked-exception", checked);
|
||||
|
||||
Map<String, Object> swallowed = new LinkedHashMap<>();
|
||||
silent.swallowsException("swallowed-1");
|
||||
swallowed.put("rowSurvived", accounts.existsById("swallowed-1"));
|
||||
swallowed.put("verdict", accounts.existsById("swallowed-1")
|
||||
? "COMMITTED -- the exception never reached the interceptor" : "rolled back");
|
||||
result.put("4-swallowed-exception", swallowed);
|
||||
|
||||
result.put("5-called-from-post-construct", Map.of(
|
||||
"transactionActiveDuringPostConstruct",
|
||||
silent.wasTransactionActiveDuringPostConstruct()));
|
||||
|
||||
result.put("6-created-with-new", new NotABean().work());
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* NESTED working, on a JDBC transaction manager, because it cannot work on a JPA one.
|
||||
*/
|
||||
@GetMapping("/tx/nested-jdbc")
|
||||
public Map<String, Object> nestedJdbc() {
|
||||
return jdbcNested.partialRollback();
|
||||
}
|
||||
|
||||
/** Isolation and read-only: declared where it counts, and declared where it is ignored. */
|
||||
@GetMapping("/tx/isolation")
|
||||
public Map<String, Object> isolation() {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("readOnlyScope", attempt(outer::readOnlyScope));
|
||||
result.put("serializableOuter",
|
||||
attempt(() -> outer.serializableScope(inner::required)));
|
||||
result.put("participantDeclaringReadUncommitted",
|
||||
attempt(() -> outer.inTransaction(note -> outer.readUncommittedParticipant())));
|
||||
return result;
|
||||
}
|
||||
|
||||
private Object attempt(java.util.function.Supplier<Map<String, Object>> action) {
|
||||
try {
|
||||
return action.get();
|
||||
} catch (RuntimeException ex) {
|
||||
return Map.of("exception", ex.getClass().getName(),
|
||||
"message", String.valueOf(ex.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/** Run something that may fail, then report how many audit rows survived. */
|
||||
private Map<String, Object> survives(Runnable action) {
|
||||
audit.deleteAll();
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
try {
|
||||
action.run();
|
||||
row.put("outcome", "returned normally");
|
||||
} catch (UnexpectedRollbackException ex) {
|
||||
row.put("outcome", "UnexpectedRollbackException");
|
||||
row.put("message", ex.getMessage());
|
||||
} catch (RuntimeException ex) {
|
||||
row.put("outcome", ex.getClass().getSimpleName());
|
||||
row.put("message", ex.getMessage());
|
||||
}
|
||||
long surviving = audit.count();
|
||||
row.put("auditRowsSurviving", surviving);
|
||||
row.put("verdict", surviving > 0 ? "inner work SURVIVED" : "inner work rolled back");
|
||||
return row;
|
||||
}
|
||||
}
|
||||
25
transactions/src/main/resources/application.yaml
Normal file
25
transactions/src/main/resources/application.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
spring:
|
||||
application:
|
||||
name: transactions
|
||||
datasource:
|
||||
url: jdbc:h2:mem:txdemo;DB_CLOSE_DELAY=-1
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: create-drop
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: false
|
||||
|
||||
server:
|
||||
port: 8081
|
||||
|
||||
logging:
|
||||
level:
|
||||
root: WARN
|
||||
# The transaction lifecycle, in the transaction manager's own words: "Creating new
|
||||
# transaction", "Participating in existing transaction", "Suspending current transaction",
|
||||
# "Initiating transaction commit/rollback". This is the log to turn on when a transaction
|
||||
# is not behaving, and it is the source of the transcripts in docs/output/.
|
||||
org.springframework.orm.jpa.JpaTransactionManager: DEBUG
|
||||
org.springframework.transaction.interceptor: TRACE
|
||||
org.hibernate.SQL: DEBUG
|
||||
@@ -0,0 +1,208 @@
|
||||
package com.ankurm.tx;
|
||||
|
||||
import com.ankurm.tx.domain.Account;
|
||||
import com.ankurm.tx.repo.AccountRepository;
|
||||
import com.ankurm.tx.repo.AuditRepository;
|
||||
import com.ankurm.tx.service.InnerService;
|
||||
import com.ankurm.tx.service.NotABean;
|
||||
import com.ankurm.tx.service.OuterService;
|
||||
import com.ankurm.tx.service.SilentlyNonTransactional;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.transaction.IllegalTransactionStateException;
|
||||
import org.springframework.transaction.NestedTransactionNotSupportedException;
|
||||
import org.springframework.transaction.UnexpectedRollbackException;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
|
||||
import static org.assertj.core.api.Assertions.assertThatNoException;
|
||||
|
||||
/**
|
||||
* Pins every behavioural claim the transactions article makes. If a future Spring version
|
||||
* changes one of them, this fails rather than the article quietly becoming wrong.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
class TransactionContractTests {
|
||||
|
||||
@Autowired OuterService outer;
|
||||
@Autowired InnerService inner;
|
||||
@Autowired SilentlyNonTransactional silent;
|
||||
@Autowired AuditRepository audit;
|
||||
@Autowired AccountRepository accounts;
|
||||
|
||||
@BeforeEach
|
||||
void reset() {
|
||||
audit.deleteAll();
|
||||
accounts.deleteAll();
|
||||
}
|
||||
|
||||
// -- propagation ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("REQUIRED joins the caller's transaction rather than starting one")
|
||||
void requiredJoins() {
|
||||
var result = outer.inTransaction(inner::required);
|
||||
String outerName = (String) ((java.util.Map<?, ?>) result.get("outer")).get("transactionName");
|
||||
String innerName = (String) ((java.util.Map<?, ?>) result.get("inner")).get("transactionName");
|
||||
assertThat(innerName).as("same transaction name means it joined").isEqualTo(outerName);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("REQUIRES_NEW starts its own transaction")
|
||||
void requiresNewStartsItsOwn() {
|
||||
var result = outer.inTransaction(inner::requiresNew);
|
||||
String outerName = (String) ((java.util.Map<?, ?>) result.get("outer")).get("transactionName");
|
||||
String innerName = (String) ((java.util.Map<?, ?>) result.get("inner")).get("transactionName");
|
||||
assertThat(innerName).isNotEqualTo(outerName).endsWith("requiresNew");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("NOT_SUPPORTED suspends the caller's transaction")
|
||||
void notSupportedSuspends() {
|
||||
var result = outer.inTransaction(inner::notSupported);
|
||||
assertThat(((java.util.Map<?, ?>) result.get("inner")).get("actualTransactionActive"))
|
||||
.isEqualTo(false);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("MANDATORY without a caller's transaction throws")
|
||||
void mandatoryRequiresOne() {
|
||||
assertThatExceptionOfType(IllegalTransactionStateException.class)
|
||||
.isThrownBy(() -> outer.withoutTransaction(inner::mandatory))
|
||||
.withMessageContaining("No existing transaction found");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("NEVER inside a transaction throws")
|
||||
void neverForbidsOne() {
|
||||
assertThatExceptionOfType(IllegalTransactionStateException.class)
|
||||
.isThrownBy(() -> outer.inTransaction(inner::never))
|
||||
.withMessageContaining("Existing transaction found");
|
||||
}
|
||||
|
||||
/**
|
||||
* The finding the article leads its NESTED section with: this propagation cannot be used
|
||||
* with the transaction manager Spring Boot configures for JPA.
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("NESTED is not supported by JpaTransactionManager")
|
||||
void nestedIsUnsupportedOnJpa() {
|
||||
assertThatExceptionOfType(NestedTransactionNotSupportedException.class)
|
||||
.isThrownBy(() -> outer.inTransaction(inner::nested))
|
||||
.withMessageContaining("does not allow nested transactions");
|
||||
}
|
||||
|
||||
// -- rollback ---------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("REQUIRED inner work does not survive the caller's rollback")
|
||||
void requiredInnerDiesWithCaller() {
|
||||
assertThatExceptionOfType(IllegalStateException.class).isThrownBy(
|
||||
() -> outer.inTransactionThenFail(note -> inner.required(note)));
|
||||
assertThat(audit.count()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("REQUIRES_NEW inner work survives the caller's rollback")
|
||||
void requiresNewInnerSurvives() {
|
||||
assertThatExceptionOfType(IllegalStateException.class).isThrownBy(
|
||||
() -> outer.inTransactionThenFail(note -> inner.requiresNew(note)));
|
||||
assertThat(audit.count()).isEqualTo(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Catching the exception does not save the transaction. The inner REQUIRED scope already
|
||||
* marked it rollback-only, so the commit fails afterwards with an exception thrown from a
|
||||
* place that has nothing to do with the original failure.
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("catching a REQUIRED inner failure still ends in UnexpectedRollbackException")
|
||||
void catchingDoesNotSaveTheTransaction() {
|
||||
assertThatExceptionOfType(UnexpectedRollbackException.class)
|
||||
.isThrownBy(() -> outer.catchInnerFailure(inner::requiredThenFail))
|
||||
.withMessageContaining("marked as rollback-only");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("catching a REQUIRES_NEW inner failure is contained")
|
||||
void requiresNewFailureIsContained() {
|
||||
assertThatNoException()
|
||||
.isThrownBy(() -> outer.catchInnerFailure(inner::requiresNewThenFail));
|
||||
}
|
||||
|
||||
// -- the silent failures ----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("self-invocation starts no transaction, while the proxied call does")
|
||||
void selfInvocationIsNotTransactional() {
|
||||
assertThat(silent.entryPoint()).endsWith("false");
|
||||
assertThat(silent.properlyCalled()).as("control").endsWith("true");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a private @Transactional method starts no transaction")
|
||||
void privateMethodIsNotTransactional() {
|
||||
assertThat(silent.callsPrivate()).endsWith("false");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a checked exception commits instead of rolling back")
|
||||
void checkedExceptionCommits() {
|
||||
assertThatExceptionOfType(Exception.class)
|
||||
.isThrownBy(() -> silent.checkedExceptionCommits("checked-1"));
|
||||
assertThat(accounts.existsById("checked-1"))
|
||||
.as("the row survived an exception the code did not handle")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("swallowing the exception commits the work it was abandoning")
|
||||
void swallowedExceptionCommits() {
|
||||
silent.swallowsException("swallowed-1");
|
||||
assertThat(accounts.existsById("swallowed-1")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("@Transactional is inactive during @PostConstruct")
|
||||
void postConstructHasNoTransaction() {
|
||||
assertThat(silent.wasTransactionActiveDuringPostConstruct()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an object created with new is never transactional")
|
||||
void newedUpObjectIsNotTransactional() {
|
||||
assertThat(new NotABean().work()).endsWith("false");
|
||||
}
|
||||
|
||||
// -- isolation --------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("an isolation level declared on a participating scope is silently ignored")
|
||||
void participantIsolationIsIgnored() {
|
||||
var result = outer.inTransaction(note -> outer.readUncommittedParticipant());
|
||||
Object innerIsolation = ((java.util.Map<?, ?>) result.get("inner")).get("isolationLevel");
|
||||
assertThat(innerIsolation)
|
||||
.as("READ_UNCOMMITTED was declared and did not take effect")
|
||||
.isEqualTo("default (from the connection)");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an isolation level declared where the transaction starts does take effect")
|
||||
void startingScopeIsolationApplies() {
|
||||
var result = outer.serializableScope(inner::required);
|
||||
assertThat(((java.util.Map<?, ?>) result.get("outer")).get("isolationLevel"))
|
||||
.isEqualTo("SERIALIZABLE");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an account row written in a rolled-back scope leaves no trace")
|
||||
void sanityCheckOnTheFixture() {
|
||||
accounts.save(new Account("sanity", 1));
|
||||
assertThat(accounts.count()).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user