diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c115eef --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +target/ +*.log +*.hprof +.idea/ +*.iml +.vscode/ diff --git a/README.md b/README.md index 6b35f97..7028aef 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,26 @@ # sdjpa4-demo -Runnable companion project for the Spring Data JPA 3 to 4 migration series on ankurm.com: +Companion code for the Spring Data JPA and transaction articles on **[ankurm.com](https://ankurm.com)**. -- [Spring Data JPA 3 to 4 Migration Guide](https://ankurm.com/spring-data-jpa-3-to-4-migration-guide/) (overview, start here) -- [Migration Baseline](https://ankurm.com/spring-data-jpa-3-to-4-migration-guide-2/) -- [Query Engine Rewrite: Criteria to JPQL](https://ankurm.com/spring-data-jpa-4-derived-queries-criteria-to-jpql/) -- [Advanced Migration: Specifications, AOT, EntityManager](https://ankurm.com/spring-data-jpa-4-specifications-aot-entitymanager/) +Two independent Maven modules under one aggregator: -Four entities, three repositories, a `@NullMarked` package, 20 passing behavior tests, and an A-L demo runner that prints every labelled console output shown in the articles. Boots H2 in memory, so there's nothing to install beyond a JDK. +| Module | Article(s) | Boot / JDK | +|---|---|---| +| [`migration-behavior/`](migration-behavior) | the four Spring Data JPA 3→4 migration articles | 4.0.6 / 21 | +| [`transactions/`](transactions) | [@Transactional: propagation, isolation and the six silent failures](https://ankurm.com/) | 4.1.1 / 25 | -Built and verified on Eclipse Temurin JDK 21, Spring Boot 4.0.6, Spring Data JPA 4.0.6, Hibernate ORM 7.2, H2 in-memory. +The modules deliberately pin different Spring Boot versions. `migration-behavior` stays on +4.0.6 because that is what the four published migration articles were written and verified +against, and upgrading it would silently invalidate output those articles quote. -## Run it +> **Moved in September 2026.** The migration project used to live at the repository root. It is +> now under `migration-behavior/`; source paths gained that prefix and nothing else changed. The +> `article-1-baseline`, `article-2-query-engine` and `article-3-advanced` tags still point at the +> original layout, so a link into a tagged tree is unaffected. -``` -git clone https://ankurm.com/git.app/asmhatre/sdjpa4-demo.git -cd sdjpa4-demo -./mvnw spring-boot:run # prints the labelled A-L demonstrations -./mvnw test # runs the 20 behavior tests (all green) +```bash +./mvnw -DskipTests package # both modules +./mvnw test # every test in both ``` ## Tags diff --git a/migration-behavior/pom.xml b/migration-behavior/pom.xml new file mode 100644 index 0000000..9723bed --- /dev/null +++ b/migration-behavior/pom.xml @@ -0,0 +1,64 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.0.6 + + + + com.ankurm + sdjpa4-demo + 1.0.0 + sdjpa4-demo + Spring Data JPA 3 to 4 migration demo (ankurm.com) + + + 21 + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + com.h2database + h2 + runtime + + + + org.jspecify + jspecify + 1.0.0 + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + + org.hibernate.orm + hibernate-processor + + + + + + + diff --git a/src/main/java/com/ankurm/sdjpa4demo/Application.java b/migration-behavior/src/main/java/com/ankurm/sdjpa4demo/Application.java similarity index 100% rename from src/main/java/com/ankurm/sdjpa4demo/Application.java rename to migration-behavior/src/main/java/com/ankurm/sdjpa4demo/Application.java diff --git a/src/main/java/com/ankurm/sdjpa4demo/DemoRunner.java b/migration-behavior/src/main/java/com/ankurm/sdjpa4demo/DemoRunner.java similarity index 100% rename from src/main/java/com/ankurm/sdjpa4demo/DemoRunner.java rename to migration-behavior/src/main/java/com/ankurm/sdjpa4demo/DemoRunner.java diff --git a/src/main/java/com/ankurm/sdjpa4demo/domain/Author.java b/migration-behavior/src/main/java/com/ankurm/sdjpa4demo/domain/Author.java similarity index 100% rename from src/main/java/com/ankurm/sdjpa4demo/domain/Author.java rename to migration-behavior/src/main/java/com/ankurm/sdjpa4demo/domain/Author.java diff --git a/src/main/java/com/ankurm/sdjpa4demo/domain/Book.java b/migration-behavior/src/main/java/com/ankurm/sdjpa4demo/domain/Book.java similarity index 100% rename from src/main/java/com/ankurm/sdjpa4demo/domain/Book.java rename to migration-behavior/src/main/java/com/ankurm/sdjpa4demo/domain/Book.java diff --git a/src/main/java/com/ankurm/sdjpa4demo/domain/BookSummary.java b/migration-behavior/src/main/java/com/ankurm/sdjpa4demo/domain/BookSummary.java similarity index 100% rename from src/main/java/com/ankurm/sdjpa4demo/domain/BookSummary.java rename to migration-behavior/src/main/java/com/ankurm/sdjpa4demo/domain/BookSummary.java diff --git a/src/main/java/com/ankurm/sdjpa4demo/domain/Money.java b/migration-behavior/src/main/java/com/ankurm/sdjpa4demo/domain/Money.java similarity index 100% rename from src/main/java/com/ankurm/sdjpa4demo/domain/Money.java rename to migration-behavior/src/main/java/com/ankurm/sdjpa4demo/domain/Money.java diff --git a/src/main/java/com/ankurm/sdjpa4demo/repo/AuthorRepository.java b/migration-behavior/src/main/java/com/ankurm/sdjpa4demo/repo/AuthorRepository.java similarity index 100% rename from src/main/java/com/ankurm/sdjpa4demo/repo/AuthorRepository.java rename to migration-behavior/src/main/java/com/ankurm/sdjpa4demo/repo/AuthorRepository.java diff --git a/src/main/java/com/ankurm/sdjpa4demo/repo/AuthorSpecifications.java b/migration-behavior/src/main/java/com/ankurm/sdjpa4demo/repo/AuthorSpecifications.java similarity index 100% rename from src/main/java/com/ankurm/sdjpa4demo/repo/AuthorSpecifications.java rename to migration-behavior/src/main/java/com/ankurm/sdjpa4demo/repo/AuthorSpecifications.java diff --git a/src/main/java/com/ankurm/sdjpa4demo/repo/BookRepository.java b/migration-behavior/src/main/java/com/ankurm/sdjpa4demo/repo/BookRepository.java similarity index 100% rename from src/main/java/com/ankurm/sdjpa4demo/repo/BookRepository.java rename to migration-behavior/src/main/java/com/ankurm/sdjpa4demo/repo/BookRepository.java diff --git a/src/main/java/com/ankurm/sdjpa4demo/repo/package-info.java b/migration-behavior/src/main/java/com/ankurm/sdjpa4demo/repo/package-info.java similarity index 100% rename from src/main/java/com/ankurm/sdjpa4demo/repo/package-info.java rename to migration-behavior/src/main/java/com/ankurm/sdjpa4demo/repo/package-info.java diff --git a/src/main/resources/application.properties b/migration-behavior/src/main/resources/application.properties similarity index 100% rename from src/main/resources/application.properties rename to migration-behavior/src/main/resources/application.properties diff --git a/src/test/java/com/ankurm/sdjpa4demo/MigrationBehaviorTests.java b/migration-behavior/src/test/java/com/ankurm/sdjpa4demo/MigrationBehaviorTests.java similarity index 100% rename from src/test/java/com/ankurm/sdjpa4demo/MigrationBehaviorTests.java rename to migration-behavior/src/test/java/com/ankurm/sdjpa4demo/MigrationBehaviorTests.java diff --git a/pom.xml b/pom.xml index 9723bed..fdd1fe9 100644 --- a/pom.xml +++ b/pom.xml @@ -2,63 +2,28 @@ - 4.0.0 + 4.0.0 - - org.springframework.boot - spring-boot-starter-parent - 4.0.6 - - + com.ankurm + sdjpa4-demo-parent + 1.0.0 + pom + sdjpa4-demo + Companion code for the Spring Data JPA and transaction articles on ankurm.com - com.ankurm - sdjpa4-demo - 1.0.0 - sdjpa4-demo - Spring Data JPA 3 to 4 migration demo (ankurm.com) + - - org.jspecify - jspecify - 1.0.0 - - - org.springframework.boot - spring-boot-starter-test - test - - - - - - - org.apache.maven.plugins - maven-compiler-plugin - - - - - org.hibernate.orm - hibernate-processor - - - - - - + Keeping them apart costs one extra pom and means neither article's evidence rots when the + other module moves on. + --> + + migration-behavior + transactions + diff --git a/transactions/README.md b/transactions/README.md new file mode 100644 index 0000000..1f7237d --- /dev/null +++ b/transactions/README.md @@ -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. diff --git a/transactions/docs/01-what-transactional-does.md b/transactions/docs/01-what-transactional-does.md new file mode 100644 index 0000000..ccb22cb --- /dev/null +++ b/transactions/docs/01-what-transactional-does.md @@ -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. diff --git a/transactions/docs/02-propagation.md b/transactions/docs/02-propagation.md new file mode 100644 index 0000000..93d5a22 --- /dev/null +++ b/transactions/docs/02-propagation.md @@ -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' +``` diff --git a/transactions/docs/03-rollback.md b/transactions/docs/03-rollback.md new file mode 100644 index 0000000..fd5d4c7 --- /dev/null +++ b/transactions/docs/03-rollback.md @@ -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. diff --git a/transactions/docs/04-nested-and-jpa.md b/transactions/docs/04-nested-and-jpa.md new file mode 100644 index 0000000..c70b736 --- /dev/null +++ b/transactions/docs/04-nested-and-jpa.md @@ -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. diff --git a/transactions/docs/05-six-silent-failures.md b/transactions/docs/05-six-silent-failures.md new file mode 100644 index 0000000..baa2a27 --- /dev/null +++ b/transactions/docs/05-six-silent-failures.md @@ -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. diff --git a/transactions/docs/06-isolation-and-readonly.md b/transactions/docs/06-isolation-and-readonly.md new file mode 100644 index 0000000..c481088 --- /dev/null +++ b/transactions/docs/06-isolation-and-readonly.md @@ -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. diff --git a/transactions/docs/output/00-versions.txt b/transactions/docs/output/00-versions.txt new file mode 100644 index 0000000..6f62621 --- /dev/null +++ b/transactions/docs/output/00-versions.txt @@ -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) diff --git a/transactions/docs/output/01-propagation.txt b/transactions/docs/output/01-propagation.txt new file mode 100644 index 0000000..d9da298 --- /dev/null +++ b/transactions/docs/output/01-propagation.txt @@ -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 diff --git a/transactions/docs/output/02-rollback.txt b/transactions/docs/output/02-rollback.txt new file mode 100644 index 0000000..5e933fd --- /dev/null +++ b/transactions/docs/output/02-rollback.txt @@ -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. diff --git a/transactions/docs/output/03-nested.txt b/transactions/docs/output/03-nested.txt new file mode 100644 index 0000000..b71242b --- /dev/null +++ b/transactions/docs/output/03-nested.txt @@ -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. diff --git a/transactions/docs/output/04-silent-failures.txt b/transactions/docs/output/04-silent-failures.txt new file mode 100644 index 0000000..eeca0c5 --- /dev/null +++ b/transactions/docs/output/04-silent-failures.txt @@ -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. diff --git a/transactions/docs/output/05-isolation.txt b/transactions/docs/output/05-isolation.txt new file mode 100644 index 0000000..8122239 --- /dev/null +++ b/transactions/docs/output/05-isolation.txt @@ -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. diff --git a/transactions/pom.xml b/transactions/pom.xml new file mode 100644 index 0000000..1b79432 --- /dev/null +++ b/transactions/pom.xml @@ -0,0 +1,53 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + com.ankurm + transactions + 1.0.0 + transactions + @Transactional: propagation, isolation, and the ways it silently does nothing + + + 25 + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-webmvc + + + com.h2database + h2 + runtime + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/transactions/scripts/demo-isolation.sh b/transactions/scripts/demo-isolation.sh new file mode 100755 index 0000000..2acf3f1 --- /dev/null +++ b/transactions/scripts/demo-isolation.sh @@ -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 diff --git a/transactions/scripts/demo-nested.sh b/transactions/scripts/demo-nested.sh new file mode 100755 index 0000000..6378eb9 --- /dev/null +++ b/transactions/scripts/demo-nested.sh @@ -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 diff --git a/transactions/scripts/demo-propagation.sh b/transactions/scripts/demo-propagation.sh new file mode 100755 index 0000000..7faa950 --- /dev/null +++ b/transactions/scripts/demo-propagation.sh @@ -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 diff --git a/transactions/scripts/demo-rollback.sh b/transactions/scripts/demo-rollback.sh new file mode 100755 index 0000000..96b49a5 --- /dev/null +++ b/transactions/scripts/demo-rollback.sh @@ -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 diff --git a/transactions/scripts/demo-silent.sh b/transactions/scripts/demo-silent.sh new file mode 100755 index 0000000..ce2c3f5 --- /dev/null +++ b/transactions/scripts/demo-silent.sh @@ -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 diff --git a/transactions/scripts/demo-versions.sh b/transactions/scripts/demo-versions.sh new file mode 100755 index 0000000..4cde230 --- /dev/null +++ b/transactions/scripts/demo-versions.sh @@ -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 'spring-boot-starter-parent' pom.xml | grep '' | sed 's/.*\(.*\)<\/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 diff --git a/transactions/scripts/env.sh b/transactions/scripts/env.sh new file mode 100755 index 0000000..dc5aa2c --- /dev/null +++ b/transactions/scripts/env.sh @@ -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 +} diff --git a/transactions/scripts/run-all.sh b/transactions/scripts/run-all.sh new file mode 100755 index 0000000..3cb00e2 --- /dev/null +++ b/transactions/scripts/run-all.sh @@ -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/ diff --git a/transactions/src/main/java/com/ankurm/tx/TransactionsApplication.java b/transactions/src/main/java/com/ankurm/tx/TransactionsApplication.java new file mode 100644 index 0000000..680cec2 --- /dev/null +++ b/transactions/src/main/java/com/ankurm/tx/TransactionsApplication.java @@ -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". + * + *

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); + } +} diff --git a/transactions/src/main/java/com/ankurm/tx/config/NestedTransactionConfig.java b/transactions/src/main/java/com/ankurm/tx/config/NestedTransactionConfig.java new file mode 100644 index 0000000..36e2015 --- /dev/null +++ b/transactions/src/main/java/com/ankurm/tx/config/NestedTransactionConfig.java @@ -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. + * + *

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: + * + *

+ * NestedTransactionNotSupportedException: Transaction manager does not allow nested
+ * transactions by default - specify 'nestedTransactionAllowed' property with value 'true'
+ * 
+ * + *

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. + * + *

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. + * + *

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; + } +} diff --git a/transactions/src/main/java/com/ankurm/tx/domain/Account.java b/transactions/src/main/java/com/ankurm/tx/domain/Account.java new file mode 100644 index 0000000..cea110f --- /dev/null +++ b/transactions/src/main/java/com/ankurm/tx/domain/Account.java @@ -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; + } +} diff --git a/transactions/src/main/java/com/ankurm/tx/domain/AuditEntry.java b/transactions/src/main/java/com/ankurm/tx/domain/AuditEntry.java new file mode 100644 index 0000000..30add67 --- /dev/null +++ b/transactions/src/main/java/com/ankurm/tx/domain/AuditEntry.java @@ -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; + } +} diff --git a/transactions/src/main/java/com/ankurm/tx/repo/AccountRepository.java b/transactions/src/main/java/com/ankurm/tx/repo/AccountRepository.java new file mode 100644 index 0000000..163d771 --- /dev/null +++ b/transactions/src/main/java/com/ankurm/tx/repo/AccountRepository.java @@ -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 { +} diff --git a/transactions/src/main/java/com/ankurm/tx/repo/AuditRepository.java b/transactions/src/main/java/com/ankurm/tx/repo/AuditRepository.java new file mode 100644 index 0000000..589bba2 --- /dev/null +++ b/transactions/src/main/java/com/ankurm/tx/repo/AuditRepository.java @@ -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 { +} diff --git a/transactions/src/main/java/com/ankurm/tx/service/InnerService.java b/transactions/src/main/java/com/ankurm/tx/service/InnerService.java new file mode 100644 index 0000000..386d606 --- /dev/null +++ b/transactions/src/main/java/com/ankurm/tx/service/InnerService.java @@ -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. + * + *

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 required(String note) { + return write("REQUIRED", note); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public Map requiresNew(String note) { + return write("REQUIRES_NEW", note); + } + + @Transactional(propagation = Propagation.NESTED) + public Map nested(String note) { + return write("NESTED", note); + } + + @Transactional(propagation = Propagation.SUPPORTS) + public Map supports(String note) { + return write("SUPPORTS", note); + } + + @Transactional(propagation = Propagation.NOT_SUPPORTED) + public Map notSupported(String note) { + return write("NOT_SUPPORTED", note); + } + + @Transactional(propagation = Propagation.MANDATORY) + public Map mandatory(String note) { + return write("MANDATORY", note); + } + + @Transactional(propagation = Propagation.NEVER) + public Map 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 write(String label, String note) { + Map 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; + } +} diff --git a/transactions/src/main/java/com/ankurm/tx/service/JdbcNestedService.java b/transactions/src/main/java/com/ankurm/tx/service/JdbcNestedService.java new file mode 100644 index 0000000..69f78d4 --- /dev/null +++ b/transactions/src/main/java/com/ankurm/tx/service/JdbcNestedService.java @@ -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. + * + *

{@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. + * + *

{@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. + * + *

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 partialRollback() { + jdbc.execute("CREATE TABLE IF NOT EXISTS nested_demo (note VARCHAR(64))"); + jdbc.update("DELETE FROM nested_demo"); + + Map 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; + } +} diff --git a/transactions/src/main/java/com/ankurm/tx/service/NotABean.java b/transactions/src/main/java/com/ankurm/tx/service/NotABean.java new file mode 100644 index 0000000..ee07b7e --- /dev/null +++ b/transactions/src/main/java/com/ankurm/tx/service/NotABean.java @@ -0,0 +1,18 @@ +package com.ankurm.tx.service; + +import org.springframework.transaction.annotation.Transactional; + +/** + * 6. The object is not a bean. + * + *

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(); + } +} diff --git a/transactions/src/main/java/com/ankurm/tx/service/OuterService.java b/transactions/src/main/java/com/ankurm/tx/service/OuterService.java new file mode 100644 index 0000000..b1085f3 --- /dev/null +++ b/transactions/src/main/java/com/ankurm/tx/service/OuterService.java @@ -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. + * + *

The {@code *AndRollback} variants throw after the inner call, which is how the article + * answers the question people actually have: does the inner work survive when the outer + * transaction fails? + */ +@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 inTransaction(Function> inner) { + Map 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 withoutTransaction(Function> inner) { + Map 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 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. + * + *

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 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 readOnlyScope() { + Map 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 serializableScope(Function> inner) { + Map 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 readUncommittedParticipant() { + return TxProbe.snapshot("inner (REQUIRED + READ_UNCOMMITTED declared)"); + } +} diff --git a/transactions/src/main/java/com/ankurm/tx/service/SilentlyNonTransactional.java b/transactions/src/main/java/com/ankurm/tx/service/SilentlyNonTransactional.java new file mode 100644 index 0000000..283a67c --- /dev/null +++ b/transactions/src/main/java/com/ankurm/tx/service/SilentlyNonTransactional.java @@ -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. + * + *

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; + } + + /** + * 1. Self-invocation. {@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(); + } + + /** + * 2. A private method. 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(); + } + + /** + * 3. A checked exception. The default rollback rule is + * {@code RuntimeException} or {@code Error}. A checked exception propagates out of the + * method and the transaction commits on the way, which is the opposite of what + * almost everyone expects the first time. + * + *

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"); + } + + /** + * 4. Swallowing the exception. 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. + } + } + + /** + * 5. Called from {@code @PostConstruct}. 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(); + } +} diff --git a/transactions/src/main/java/com/ankurm/tx/service/TxProbe.java b/transactions/src/main/java/com/ankurm/tx/service/TxProbe.java new file mode 100644 index 0000000..663e781 --- /dev/null +++ b/transactions/src/main/java/com/ankurm/tx/service/TxProbe.java @@ -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. + * + *

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. + * + *

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 snapshot(String where) { + Map 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; + }; + } +} diff --git a/transactions/src/main/java/com/ankurm/tx/web/TransactionEndpoint.java b/transactions/src/main/java/com/ankurm/tx/web/TransactionEndpoint.java new file mode 100644 index 0000000..2425150 --- /dev/null +++ b/transactions/src/main/java/com/ankurm/tx/web/TransactionEndpoint.java @@ -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 propagation() { + audit.deleteAll(); + Map result = new LinkedHashMap<>(); + + record Case(String name, java.util.function.Function> 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 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 rollback() { + Map 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 silent() { + Map 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 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 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 nestedJdbc() { + return jdbcNested.partialRollback(); + } + + /** Isolation and read-only: declared where it counts, and declared where it is ignored. */ + @GetMapping("/tx/isolation") + public Map isolation() { + Map 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> 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 survives(Runnable action) { + audit.deleteAll(); + Map 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; + } +} diff --git a/transactions/src/main/resources/application.yaml b/transactions/src/main/resources/application.yaml new file mode 100644 index 0000000..b085719 --- /dev/null +++ b/transactions/src/main/resources/application.yaml @@ -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 diff --git a/transactions/src/test/java/com/ankurm/tx/TransactionContractTests.java b/transactions/src/test/java/com/ankurm/tx/TransactionContractTests.java new file mode 100644 index 0000000..4beda5f --- /dev/null +++ b/transactions/src/test/java/com/ankurm/tx/TransactionContractTests.java @@ -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); + } +}