Add core-events module: @EventListener, @TransactionalEventListener phases and async listeners on Boot 4.1

Thirteen captured transcripts: a listener is a blocking method call, ordering and chaining, SpEL conditions,
generic-event erasure, every transaction phase on commit and rollback, what an AFTER_COMMIT listener can
write, which exceptions reach the publisher, async listeners on platform and virtual threads, and the
annotations behind Spring Modulith's @ApplicationModuleListener.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Uu7q8vPeREyT4218EJPzz1
This commit is contained in:
Claude
2026-09-24 07:08:47 +00:00
parent c418589251
commit d6b76c57db
72 changed files with 1658 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
# core-events
Companion project for the article **[Spring Application Events: @EventListener, @TransactionalEventListener and Async Events](https://ankurm.com/spring-application-events-eventlistener-transactionaleventlistener-async/)** on **[ankurm.com](https://ankurm.com)**.
Every console block quoted in the article came out of `output/`. Transcripts 01-11 are written by the test
suite (so a claim that stops being true turns the build red); 12-13 are read out of jars by
`scripts/capture-facts.sh`.
There is deliberately **no `docs/` folder**: the deeper material lives in collapsible "going deeper"
sections inside the article itself, next to the paragraph each one extends.
## Versions
| | |
|---|---|
| Spring Boot | 4.1.1 |
| Spring Framework | 7.0.9 |
| JDK | 25 (Temurin 25.0.4.1+1) |
| Maven | 3.9 |
| H2 | the version Boot 4.1.1 manages |
| Spring Modulith (javap only) | 2.1.1 |
## Quickstart
```bash
export JAVA_HOME=/path/to/jdk-25
mvn test # runs the scenarios and rewrites output/01-11
./scripts/run-all.sh # everything, including 12-13 (needs network access to Maven Central once)
```
## Source layout
Each scenario is its own package with its own `@SpringBootApplication`, which scans only that package, so a
scenario starts with exactly the beans it shows and nothing else.
| Package | What it holds |
|---|---|
| `basics/` | `OrderService` publishes an `OrderPlaced` record; two listeners; the publisher blocks |
| `ordering/` | `@Order` across two beans, an event returned from a listener, a listener that throws |
| `conditional/`, `badcondition/` | SpEL `condition`s, and one that names a property that does not exist |
| `generics/` | `Created<T>` and its erasure problem, and `ResolvableTypeProvider` |
| `tx/` | one listener per transaction phase, a publisher that commits or rolls back, `fallbackExecution` |
| `txwrite/` | an `AFTER_COMMIT` listener that writes: nothing declared, `REQUIRES_NEW`, plain `@Transactional` |
| `asyncdemo/` | `@Async` listeners, the thread they land on, and what they see of the publisher's transaction |
| `asyncplain/` | the same `@Async` listener in an application that forgot `@EnableAsync` |
| `support/` | `Trace` (the in-memory log the tests print), `Modes`, and `Visibility` (asks "could another connection see this row?") |
## Captured output
| File | What it shows |
|---|---|
| `01-publish-is-a-method-call.txt` | a listener runs on the publisher's thread, before `publishEvent` returns |
| `02-order-and-chaining.txt` | `@Order` across beans, an event returned from a listener, and a throwing listener stopping the rest |
| `03-conditional-listeners.txt` | which SpEL-conditioned listeners ran for four events; the failure for a misspelled property |
| `04-generic-events-and-erasure.txt` | a generic event that reaches no listener, and the `ResolvableTypeProvider` fix |
| `05-transaction-phases-commit-and-rollback.txt` | every phase for a commit and for a rollback, with row visibility from another connection |
| `06-no-transaction-drops-the-event.txt` | publishing outside a transaction, and `fallbackExecution` |
| `07-after-commit-writes.txt` | an `AFTER_COMMIT` listener that inserts, declared three ways |
| `08-exceptions-and-transactions.txt` | which listener's exception reaches the caller, and what it does to the row |
| `09-async-listener-threads.txt` | an `@Async` listener on a platform thread, on a virtual thread, and with `@EnableAsync` forgotten |
| `10-async-and-the-transaction.txt` | what an async listener can see of the publisher's transaction |
| `11-async-exceptions.txt` | an async listener's exception: not the publisher's problem, but logged |
| `12-modulith-application-module-listener.txt` | the annotations `@ApplicationModuleListener` is made of |
| `13-transaction-cleanup-calls.txt` | the calls `DataSourceTransactionManager` makes when it cleans up |
@@ -0,0 +1,6 @@
# OrderService publishes an OrderPlaced; the listeners run before publishEvent returns, on the same thread
OrderService.place: publishing on main
AuditListener.audit: received OrderPlaced[id=A-1, amount=42] on main
AuditListener.counted: no parameter, still called
OrderService.place: publishEvent returned
@@ -0,0 +1,18 @@
# Listener order, an event returned from a listener, and a listener that throws
--- all listeners succeed ---
earliest (@Order(-10), a different bean)
receipt (@Order(0)) returns a ReceiptIssued
onReceipt received ReceiptIssued[orderId=A-1]
first (@Order(1))
second (@Order(2))
unordered (no @Order)
--- the @Order(2) listener throws ---
earliest (@Order(-10), a different bean)
receipt (@Order(0)) returns a ReceiptIssued
onReceipt received ReceiptIssued[orderId=A-2]
first (@Order(1))
second (@Order(2))
publishEvent threw: IllegalStateException: second listener failed
@@ -0,0 +1,21 @@
# @EventListener(condition = ...) evaluated against the event, and a condition that names a property that does not exist
--- OrderPlaced(VIP-1, 150) ---
large (amount >= 100)
vip (id starts with VIP)
both (large and VIP)
--- OrderPlaced(VIP-2, 20) ---
vip (id starts with VIP)
--- OrderPlaced(X-3, 500) ---
large (amount >= 100)
--- OrderPlaced(X-4, 1) ---
(no listener ran)
--- a condition that says #event.amountt ---
context started: true
publishEvent threw: SpelEvaluationException
root cause: EL1008E: Property or field 'amountt' cannot be found on object of type 'com.ankurm.events.badcondition.OrderPlaced' - maybe not public or not valid?
@@ -0,0 +1,18 @@
# Created<Order> and Created<Customer> published as plain payloads, then the same with ResolvableTypeProvider
--- publishing Created<Order> ---
Created<?> listener got a Order
publishEvent returned normally
--- publishing Created<Customer> ---
Created<?> listener got a Customer
publishEvent returned normally
--- publishing TypedCreated<Order> ---
TypedCreated<Order> listener got a Order
publishEvent returned normally
--- publishing TypedCreated<Customer> ---
TypedCreated<Customer> listener got a Customer
publishEvent returned normally
@@ -0,0 +1,21 @@
# The same listeners, one publisher call that commits and one that rolls back
--- place("ok-1", fail = false) ---
OrderService.place: row inserted, publishing
@EventListener tx active: true row visible to other connections: false
OrderService.place: leaving the method, commit follows
BEFORE_COMMIT tx active: true row visible to other connections: false
AFTER_COMMIT tx active: true row visible to other connections: true
AFTER_COMPLETION tx active: true row visible to other connections: true
--- place("bad-1", fail = true) ---
OrderService.place: row inserted, publishing
@EventListener tx active: true row visible to other connections: false
AFTER_ROLLBACK tx active: true row visible to other connections: false
AFTER_COMPLETION tx active: true row visible to other connections: false
caller saw: IllegalStateException: payment declined
--- rows afterwards ---
ok-1 rows : 1
bad-1 rows: 0
@@ -0,0 +1,5 @@
# Publishing outside any transaction: which listeners run
OrderService.placeWithoutTransaction: row inserted (auto-commit), publishing
@EventListener tx active: false row visible to other connections: true
AFTER_COMMIT or immediately (fallbackExecution = true)
@@ -0,0 +1,16 @@
# An AFTER_COMMIT listener that inserts a row: three ways to declare it
--- no transaction attribute on the listener ---
inside the listener, right after the insert: audit row visible to other connections: false
after place() returned, order rows: 1
after place() returned, audit rows: 1
--- @Transactional(propagation = REQUIRES_NEW) on the listener ---
order rows: 1
audit rows: 1
--- @Transactional (REQUIRED, the default) on the listener ---
context started: false
failure chain: BeanInitializationException -> IllegalStateException
root cause: @TransactionalEventListener method must not be annotated with @Transactional unless when declared as REQUIRES_NEW or NOT_SUPPORTED: void com.ankurm.events.txwrite.required.AuditRequired.audit(com.ankurm.events.txwrite.shared.OrderPlaced)
@@ -0,0 +1,16 @@
# Which listener's exception reaches the caller, and whether the order row survives
--- plain listener throws ---
caller saw: IllegalStateException: plain listener failed
order row survived: false
--- beforeCommit listener throws ---
caller saw: IllegalStateException: BEFORE_COMMIT listener failed
order row survived: false
--- afterCommit listener throws ---
caller saw: nothing
order row survived: true
logged: TransactionSynchronization.afterCompletion threw exception
logged: java.lang.IllegalStateException: AFTER_COMMIT listener failed
@@ -0,0 +1,15 @@
# @Async @EventListener: the publisher moves on first; which thread the listener gets
--- spring.threads.virtual.enabled=false ---
publishing on main
publishEvent returned, releasing the listener
@Async @EventListener: running on platform thread task-1, after publishEvent returned
--- spring.threads.virtual.enabled=true ---
publishing on main
publishEvent returned, releasing the listener
@Async @EventListener: running on a virtual thread, after publishEvent returned
--- the same listener in an application without @EnableAsync ---
@Async @EventListener: running on main
@@ -0,0 +1,5 @@
# Two async listeners for an event published inside a transaction
@Async @EventListener: transaction active on this thread: false; row visible: false
AsyncOrderService.place: leaving the method, commit follows
@Async @TransactionalEventListener(AFTER_COMMIT): on a virtual thread; row visible: true
@@ -0,0 +1,4 @@
# An @Async listener that throws: what the publisher sees, and where the exception goes
publishEvent returned normally
logged: Unexpected exception occurred invoking async method: void com.ankurm.events.asyncdemo.AsyncListeners.boom(com.ankurm.events.asyncdemo.Exploding)
@@ -0,0 +1,5 @@
# spring-modulith-events-api 2.1.1: the annotations that @ApplicationModuleListener carries (reflection)
@org.springframework.scheduling.annotation.Async("")
@org.springframework.transaction.annotation.Transactional(propagation=REQUIRES_NEW, rollbackForClassName={}, readOnly=false, transactionManager="", isolation=DEFAULT, timeoutString="", label={}, noRollbackFor={}, noRollbackForClassName={}, value="", timeout=-1, rollbackFor={})
@org.springframework.transaction.event.TransactionalEventListener(phase=AFTER_COMMIT, condition="", fallbackExecution=false, id="", value={}, classes={})
@@ -0,0 +1,13 @@
# DataSourceTransactionManager.doCleanupAfterCompletion in spring-jdbc 7.0.9: the calls it makes (javap -c)
org/springframework/jdbc/datasource/DataSourceTransactionManager$DataSourceTransactionObject.isNewConnectionHolder:()Z
obtainDataSource:()Ljavax/sql/DataSource;
org/springframework/transaction/support/TransactionSynchronizationManager.unbindResource:(Ljava/lang/Object;)Ljava/lang/Object;
org/springframework/jdbc/datasource/DataSourceTransactionManager$DataSourceTransactionObject.getConnectionHolder:()Lorg/springframework/jdbc/datasource/ConnectionHolder;
org/springframework/jdbc/datasource/ConnectionHolder.getConnection:()Ljava/sql/Connection;
org/springframework/jdbc/datasource/DataSourceTransactionManager$DataSourceTransactionObject.isMustRestoreAutoCommit:()Z
java/sql/Connection.setAutoCommit:(Z)V
org/springframework/jdbc/datasource/DataSourceTransactionManager$DataSourceTransactionObject.getPreviousIsolationLevel:()Ljava/lang/Integer;
org/springframework/jdbc/datasource/DataSourceTransactionManager$DataSourceTransactionObject.isReadOnly:()Z
isDefaultReadOnly:()Z
org/springframework/jdbc/datasource/DataSourceUtils.resetConnectionAfterTransaction:(Ljava/sql/Connection;Ljava/lang/Integer;Z)V
+49
View File
@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>core-events</artifactId>
<version>1.0.0</version>
<name>core-events</name>
<description>Spring application events in Boot 4: @EventListener, @TransactionalEventListener and async listeners</description>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Facts that come from jars rather than from running the scenarios:
# 12 what Spring Modulith's @ApplicationModuleListener is made of (read with javap)
# 13 what DataSourceTransactionManager does when a transaction is cleaned up (read with javap)
set -euo pipefail
cd "$(dirname "$0")/.."
mkdir -p output target/facts
M2=~/.m2/repository
MODULITH=2.1.1 # newest GA in maven-metadata.xml at the time of writing; 2.2.0-M1 is a milestone
fetch() { # url, destination: Maven Central rate-limits shared addresses, so retry politely
for i in 1 2 3 4 5 6; do
code=$(curl -s -o "$2" -w '%{http_code}' "$1" || true)
[ "$code" = 200 ] && return 0
sleep $((i * 10))
done
echo "could not fetch $1 (last status $code)" >&2; return 1
}
JAR=target/facts/spring-modulith-events-api-$MODULITH.jar
[ -s "$JAR" ] || fetch "https://repo1.maven.org/maven2/org/springframework/modulith/spring-modulith-events-api/$MODULITH/spring-modulith-events-api-$MODULITH.jar" "$JAR"
SPRING=7.0.9
CP="$JAR"
for a in spring-core spring-context spring-tx spring-aop spring-beans; do
CP="$CP:$(find "$M2/org/springframework/$a/$SPRING" -name "$a-$SPRING.jar" | head -1)"
done
{
echo "# spring-modulith-events-api $MODULITH: the annotations that @ApplicationModuleListener carries (reflection)"
echo
java -cp "$CP" scripts/facts/PrintAnnotations.java org.springframework.modulith.events.ApplicationModuleListener 2>/dev/null \
| grep -E 'Async|Transactional'
} > output/12-modulith-application-module-listener.txt
cat output/12-modulith-application-module-listener.txt
SPRINGJDBC=$(find "$M2/org/springframework/spring-jdbc/7.0.9" -name 'spring-jdbc-7.0.9.jar' | head -1)
rm -rf target/facts/jdbc && mkdir -p target/facts/jdbc
unzip -o -q "$SPRINGJDBC" 'org/springframework/jdbc/datasource/DataSourceTransactionManager.class' -d target/facts/jdbc
{
echo "# DataSourceTransactionManager.doCleanupAfterCompletion in spring-jdbc 7.0.9: the calls it makes (javap -c)"
echo
javap -c -p -cp target/facts/jdbc org.springframework.jdbc.datasource.DataSourceTransactionManager \
| awk '/void doCleanupAfterCompletion/,/resetConnectionAfterTransaction/' | grep -E 'invoke' | sed -E 's/^.*\/\/ (Interface)?Method //'
} > output/13-transaction-cleanup-calls.txt
cat output/13-transaction-cleanup-calls.txt
@@ -0,0 +1,11 @@
import java.lang.annotation.Annotation;
/** Prints the annotations a class carries, as the JVM reports them. Usage: java PrintAnnotations.java <class name> */
public class PrintAnnotations {
public static void main(String[] args) throws Exception {
Class<?> type = Class.forName(args[0]);
for (Annotation a : type.getAnnotations()) {
System.out.println(a);
}
}
}
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Regenerates every file under output/.
#
# ./scripts/run-all.sh
#
# Needs a JDK 25 and Maven 3.9. Transcripts 01-11 are written by the test suite, so each figure in the
# article is an assertion that fails the build if it stops being true. Transcripts 12-13 come from javap.
set -euo pipefail
cd "$(dirname "$0")/.."
echo "== test suite (transcripts 01-11)"
mvn -B test
echo "== facts read from jars (12-13)"
./scripts/capture-facts.sh
echo
echo "output:"
ls -1 output
@@ -0,0 +1,7 @@
package com.ankurm.events.asyncdemo;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication(scanBasePackages = {"com.ankurm.events.asyncdemo", "com.ankurm.events.support"})
public class AsyncApp {
}
@@ -0,0 +1,10 @@
package com.ankurm.events.asyncdemo;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
/** Without this annotation @Async does nothing: see the last section of output/09. */
@Configuration(proxyBeanMethods = false)
@EnableAsync
public class AsyncConfig {
}
@@ -0,0 +1,57 @@
package com.ankurm.events.asyncdemo;
import static org.springframework.transaction.event.TransactionPhase.AFTER_COMMIT;
import com.ankurm.events.support.Trace;
import com.ankurm.events.support.Visibility;
import java.util.concurrent.TimeUnit;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionalEventListener;
import org.springframework.transaction.support.TransactionSynchronizationManager;
@Component
public class AsyncListeners {
private final Gate gate;
private final Visibility visibility;
public AsyncListeners(Gate gate, Visibility visibility) {
this.gate = gate;
this.visibility = visibility;
}
/** Waits until the publisher has moved on, which it can only do if this listener did not block it. */
@Async
@EventListener
void slow(OrderPlaced event) throws InterruptedException {
gate.publishReturned.await(5, TimeUnit.SECONDS);
Trace.add("@Async @EventListener: running on %s, after publishEvent returned", Trace.where());
gate.slowDone.countDown();
}
/** Looks for the publisher's row while the publisher's transaction is still open. */
@Async
@EventListener
void readsTheRow(RowCheck event) {
Trace.add("@Async @EventListener: transaction active on this thread: %s; row visible: %s",
TransactionSynchronizationManager.isActualTransactionActive(), visibility.orderVisibleElsewhere(event.id()));
gate.plainDone.countDown();
}
/** The same look, but not until the publisher has committed. */
@Async
@TransactionalEventListener(phase = AFTER_COMMIT)
void readsTheRowAfterCommit(RowCheck event) {
Trace.add("@Async @TransactionalEventListener(AFTER_COMMIT): on %s; row visible: %s", Trace.where(),
visibility.orderVisibleElsewhere(event.id()));
gate.commitDone.countDown();
}
@Async
@EventListener
void boom(Exploding event) {
throw new IllegalStateException("async listener blew up");
}
}
@@ -0,0 +1,31 @@
package com.ankurm.events.asyncdemo;
import com.ankurm.events.support.Trace;
import java.util.concurrent.TimeUnit;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class AsyncOrderService {
private final JdbcClient jdbc;
private final ApplicationEventPublisher publisher;
private final Gate gate;
public AsyncOrderService(JdbcClient jdbc, ApplicationEventPublisher publisher, Gate gate) {
this.jdbc = jdbc;
this.publisher = publisher;
this.gate = gate;
}
@Transactional
public void place(String id) throws InterruptedException {
jdbc.sql("insert into orders(id, amount) values (?, 1)").param(id).update();
publisher.publishEvent(new RowCheck(id));
// Hold the transaction open until the plain async listener has looked, so its answer cannot be a race.
gate.plainDone.await(5, TimeUnit.SECONDS);
Trace.add("AsyncOrderService.place: leaving the method, commit follows");
}
}
@@ -0,0 +1,4 @@
package com.ankurm.events.asyncdemo;
public record Exploding(String id) {
}
@@ -0,0 +1,22 @@
package com.ankurm.events.asyncdemo;
import java.util.concurrent.CountDownLatch;
import org.springframework.stereotype.Component;
/** Latches that make the timing in the scenarios deterministic. */
@Component
public class Gate {
/** Released by the test once {@code publishEvent} has returned. */
public volatile CountDownLatch publishReturned = new CountDownLatch(1);
public volatile CountDownLatch slowDone = new CountDownLatch(1);
public volatile CountDownLatch plainDone = new CountDownLatch(1);
public volatile CountDownLatch commitDone = new CountDownLatch(1);
public void reset() {
publishReturned = new CountDownLatch(1);
slowDone = new CountDownLatch(1);
plainDone = new CountDownLatch(1);
commitDone = new CountDownLatch(1);
}
}
@@ -0,0 +1,4 @@
package com.ankurm.events.asyncdemo;
public record OrderPlaced(String id) {
}
@@ -0,0 +1,4 @@
package com.ankurm.events.asyncdemo;
public record RowCheck(String id) {
}
@@ -0,0 +1,7 @@
package com.ankurm.events.asyncplain;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class AsyncPlainApp {
}
@@ -0,0 +1,17 @@
package com.ankurm.events.asyncplain;
import com.ankurm.events.support.Trace;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
/** Marked @Async, in an application that never says @EnableAsync. */
@Component
public class ForgottenEnableAsyncListener {
@Async
@EventListener
void listen(OrderPlaced event) {
Trace.add("@Async @EventListener: running on %s", Trace.where());
}
}
@@ -0,0 +1,4 @@
package com.ankurm.events.asyncplain;
public record OrderPlaced(String id) {
}
@@ -0,0 +1,7 @@
package com.ankurm.events.badcondition;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class BadConditionApp {
}
@@ -0,0 +1,4 @@
package com.ankurm.events.badcondition;
public record OrderPlaced(String id, int amount) {
}
@@ -0,0 +1,15 @@
package com.ankurm.events.badcondition;
import com.ankurm.events.support.Trace;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component
public class TypoListener {
/** "amountt" is not a property of OrderPlaced. */
@EventListener(condition = "#event.amountt >= 100")
void large(OrderPlaced event) {
Trace.add("never reached");
}
}
@@ -0,0 +1,20 @@
package com.ankurm.events.basics;
import com.ankurm.events.support.Trace;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component
public class AuditListener {
@EventListener
void audit(OrderPlaced event) {
Trace.add("AuditListener.audit: received %s on %s", event, Trace.where());
}
/** A listener that does not need the event can name the type in the annotation instead. */
@EventListener(OrderPlaced.class)
void counted() {
Trace.add("AuditListener.counted: no parameter, still called");
}
}
@@ -0,0 +1,8 @@
package com.ankurm.events.basics;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/** Scans only this package, so each scenario starts with exactly its own beans. */
@SpringBootApplication
public class BasicsApp {
}
@@ -0,0 +1,5 @@
package com.ankurm.events.basics;
/** An event. A plain record: nothing to extend and nothing to implement. */
public record OrderPlaced(String id, int amount) {
}
@@ -0,0 +1,21 @@
package com.ankurm.events.basics;
import com.ankurm.events.support.Trace;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
private final ApplicationEventPublisher publisher;
public OrderService(ApplicationEventPublisher publisher) {
this.publisher = publisher;
}
public void place(String id, int amount) {
Trace.add("OrderService.place: publishing on %s", Trace.where());
publisher.publishEvent(new OrderPlaced(id, amount));
Trace.add("OrderService.place: publishEvent returned");
}
}
@@ -0,0 +1,7 @@
package com.ankurm.events.conditional;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ConditionalApp {
}
@@ -0,0 +1,28 @@
package com.ankurm.events.conditional;
import com.ankurm.events.support.Trace;
import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
@Component
public class ConditionalListeners {
@EventListener(condition = "#event.amount >= 100")
@Order(1)
void large(OrderPlaced event) {
Trace.add("large (amount >= 100)");
}
@EventListener(condition = "#event.id.startsWith('VIP')")
@Order(2)
void vip(OrderPlaced event) {
Trace.add("vip (id starts with VIP)");
}
@EventListener(condition = "#event.amount >= 100 and #event.id.startsWith('VIP')")
@Order(3)
void both(OrderPlaced event) {
Trace.add("both (large and VIP)");
}
}
@@ -0,0 +1,4 @@
package com.ankurm.events.conditional;
public record OrderPlaced(String id, int amount) {
}
@@ -0,0 +1,5 @@
package com.ankurm.events.generics;
/** A generic event. Erasure means the JVM cannot tell Created&lt;Order&gt; from Created&lt;Customer&gt;. */
public record Created<T>(T value) {
}
@@ -0,0 +1,4 @@
package com.ankurm.events.generics;
public record Customer(String name) {
}
@@ -0,0 +1,35 @@
package com.ankurm.events.generics;
import com.ankurm.events.support.Trace;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component
public class GenericListeners {
@EventListener
void createdOrder(Created<Order> event) {
Trace.add("Created<Order> listener got a %s", event.value().getClass().getSimpleName());
}
@EventListener
void createdCustomer(Created<Customer> event) {
Trace.add("Created<Customer> listener got a %s", event.value().getClass().getSimpleName());
}
/** A wildcard says "any Created", which is the one form that matches a payload whose type argument is unknown. */
@EventListener
void anyCreated(Created<?> event) {
Trace.add("Created<?> listener got a %s", event.value().getClass().getSimpleName());
}
@EventListener
void typedOrder(TypedCreated<Order> event) {
Trace.add("TypedCreated<Order> listener got a %s", event.value().getClass().getSimpleName());
}
@EventListener
void typedCustomer(TypedCreated<Customer> event) {
Trace.add("TypedCreated<Customer> listener got a %s", event.value().getClass().getSimpleName());
}
}
@@ -0,0 +1,7 @@
package com.ankurm.events.generics;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class GenericsApp {
}
@@ -0,0 +1,4 @@
package com.ankurm.events.generics;
public record Order(String id) {
}
@@ -0,0 +1,13 @@
package com.ankurm.events.generics;
import org.springframework.core.ResolvableType;
import org.springframework.core.ResolvableTypeProvider;
/** The same event, telling Spring what T is at run time. */
public record TypedCreated<T>(T value) implements ResolvableTypeProvider {
@Override
public ResolvableType getResolvableType() {
return ResolvableType.forClassWithGenerics(getClass(), ResolvableType.forInstance(value));
}
}
@@ -0,0 +1,17 @@
package com.ankurm.events.ordering;
import com.ankurm.events.support.Trace;
import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
/** Sits in another bean, and the class name sorts after the other one. @Order still decides. */
@Component
public class EarlyListener {
@EventListener
@Order(-10)
void earliest(OrderPlaced event) {
Trace.add("earliest (@Order(-10), a different bean)");
}
}
@@ -0,0 +1,4 @@
package com.ankurm.events.ordering;
public record OrderPlaced(String id) {
}
@@ -0,0 +1,7 @@
package com.ankurm.events.ordering;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class OrderingApp {
}
@@ -0,0 +1,45 @@
package com.ankurm.events.ordering;
import com.ankurm.events.support.Modes;
import com.ankurm.events.support.Trace;
import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
@Component
public class OrderingListeners {
@EventListener
@Order(1)
void first(OrderPlaced event) {
Trace.add("first (@Order(1))");
}
@EventListener
@Order(2)
void second(OrderPlaced event) {
Trace.add("second (@Order(2))");
if (Modes.fails("second")) {
throw new IllegalStateException("second listener failed");
}
}
/** No @Order: sorts last. */
@EventListener
void unordered(OrderPlaced event) {
Trace.add("unordered (no @Order)");
}
/** A returned event is published straight away, from inside this listener's turn. */
@EventListener
@Order(0)
ReceiptIssued receipt(OrderPlaced event) {
Trace.add("receipt (@Order(0)) returns a ReceiptIssued");
return new ReceiptIssued(event.id());
}
@EventListener
void onReceipt(ReceiptIssued event) {
Trace.add("onReceipt received %s", event);
}
}
@@ -0,0 +1,4 @@
package com.ankurm.events.ordering;
public record ReceiptIssued(String orderId) {
}
@@ -0,0 +1,15 @@
package com.ankurm.events.support;
/** Switches a scenario reads at run time so one set of beans can show both the working and the failing case. */
public final class Modes {
/** Name of the listener that should throw, or {@code ""} for none. */
public static volatile String failIn = "";
private Modes() {
}
public static boolean fails(String listener) {
return failIn.equals(listener);
}
}
@@ -0,0 +1,34 @@
package com.ankurm.events.support;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
/** A tiny in-memory log the scenarios write to and the tests print, so the order of events is data and not a guess. */
public final class Trace {
private static final List<String> LINES = new CopyOnWriteArrayList<>();
private Trace() {
}
public static void add(String format, Object... args) {
LINES.add(args.length == 0 ? format : String.format(format, args));
}
/** Returns everything recorded so far and clears the log. */
public static List<String> drain() {
var copy = new ArrayList<>(LINES);
LINES.clear();
return copy;
}
/** Which kind of thread is running this code, in words that stay the same from run to run. */
public static String where() {
Thread t = Thread.currentThread();
if (t.isVirtual()) {
return "a virtual thread";
}
return t.getName().equals("main") ? "main" : "platform thread " + t.getName();
}
}
@@ -0,0 +1,36 @@
package com.ankurm.events.support;
import java.sql.SQLException;
import javax.sql.DataSource;
import org.springframework.stereotype.Component;
/**
* Answers "could somebody else see this row right now?" by asking on a brand-new connection. A connection the
* pool hands out here is not the one the transaction holds, so it sees only what has been committed.
*/
@Component
public class Visibility {
private final DataSource dataSource;
public Visibility(DataSource dataSource) {
this.dataSource = dataSource;
}
public boolean orderVisibleElsewhere(String id) {
return count("orders", id) == 1;
}
public int count(String table, String id) {
try (var connection = dataSource.getConnection();
var statement = connection.prepareStatement("select count(*) from " + table + " where id = ?")) {
statement.setString(1, id);
try (var rows = statement.executeQuery()) {
rows.next();
return rows.getInt(1);
}
} catch (SQLException e) {
throw new IllegalStateException(e);
}
}
}
@@ -0,0 +1,19 @@
package com.ankurm.events.tx;
import com.ankurm.events.support.Trace;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionalEventListener;
/** Only present when a scenario asks for it, so the phase transcripts show the five phase listeners and nothing else. */
@Component
@ConditionalOnProperty("events.fallback")
public class FallbackListener {
/** Runs after commit when there is a transaction, and immediately when there is none. */
@TransactionalEventListener(fallbackExecution = true)
@org.springframework.core.annotation.Order(1)
void afterCommitOrNow(OrderPlaced event) {
Trace.add("AFTER_COMMIT or immediately (fallbackExecution = true)");
}
}
@@ -0,0 +1,4 @@
package com.ankurm.events.tx;
public record OrderPlaced(String id, int amount) {
}
@@ -0,0 +1,37 @@
package com.ankurm.events.tx;
import com.ankurm.events.support.Trace;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderService {
private final JdbcClient jdbc;
private final ApplicationEventPublisher publisher;
public OrderService(JdbcClient jdbc, ApplicationEventPublisher publisher) {
this.jdbc = jdbc;
this.publisher = publisher;
}
@Transactional
public void place(String id, int amount, boolean fail) {
jdbc.sql("insert into orders(id, amount) values (?, ?)").params(id, amount).update();
Trace.add("OrderService.place: row inserted, publishing");
publisher.publishEvent(new OrderPlaced(id, amount));
if (fail) {
throw new IllegalStateException("payment declined");
}
Trace.add("OrderService.place: leaving the method, commit follows");
}
/** Not transactional: there is no transaction for a transactional listener to wait for. */
public void placeWithoutTransaction(String id, int amount) {
jdbc.sql("insert into orders(id, amount) values (?, ?)").params(id, amount).update();
Trace.add("OrderService.placeWithoutTransaction: row inserted (auto-commit), publishing");
publisher.publishEvent(new OrderPlaced(id, amount));
}
}
@@ -0,0 +1,74 @@
package com.ankurm.events.tx;
import static org.springframework.transaction.event.TransactionPhase.AFTER_COMMIT;
import static org.springframework.transaction.event.TransactionPhase.AFTER_COMPLETION;
import static org.springframework.transaction.event.TransactionPhase.AFTER_ROLLBACK;
import static org.springframework.transaction.event.TransactionPhase.BEFORE_COMMIT;
import com.ankurm.events.support.Modes;
import com.ankurm.events.support.Trace;
import com.ankurm.events.support.Visibility;
import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.transaction.event.TransactionalEventListener;
/**
* Each listener carries an explicit {@code @Order}: two listeners in the same phase otherwise run in whatever order
* reflection lists the methods, and the transcripts would not be repeatable.
*/
@Component
public class PhaseListeners {
private final Visibility visibility;
public PhaseListeners(Visibility visibility) {
this.visibility = visibility;
}
private void note(String name, OrderPlaced event) {
Trace.add("%-38s tx active: %-5s row visible to other connections: %s", name,
TransactionSynchronizationManager.isActualTransactionActive(), visibility.orderVisibleElsewhere(event.id()));
}
@EventListener
@Order(0)
void plain(OrderPlaced event) {
note("@EventListener", event);
if (Modes.fails("plain")) {
throw new IllegalStateException("plain listener failed");
}
}
@TransactionalEventListener(phase = BEFORE_COMMIT)
@Order(1)
void beforeCommit(OrderPlaced event) {
note("BEFORE_COMMIT", event);
if (Modes.fails("beforeCommit")) {
throw new IllegalStateException("BEFORE_COMMIT listener failed");
}
}
/** The default phase. */
@TransactionalEventListener
@Order(2)
void afterCommit(OrderPlaced event) {
note("AFTER_COMMIT", event);
if (Modes.fails("afterCommit")) {
throw new IllegalStateException("AFTER_COMMIT listener failed");
}
}
@TransactionalEventListener(phase = AFTER_ROLLBACK)
@Order(3)
void afterRollback(OrderPlaced event) {
note("AFTER_ROLLBACK", event);
}
@TransactionalEventListener(phase = AFTER_COMPLETION)
@Order(4)
void afterCompletion(OrderPlaced event) {
note("AFTER_COMPLETION", event);
}
}
@@ -0,0 +1,7 @@
package com.ankurm.events.tx;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication(scanBasePackages = {"com.ankurm.events.tx", "com.ankurm.events.support"})
public class TxApp {
}
@@ -0,0 +1,25 @@
package com.ankurm.events.txwrite.newtx;
import com.ankurm.events.txwrite.shared.OrderPlaced;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.event.TransactionalEventListener;
@Component
public class AuditNewTx {
private final JdbcClient jdbc;
public AuditNewTx(JdbcClient jdbc) {
this.jdbc = jdbc;
}
/** The write gets a transaction of its own, which is the only kind a listener that runs after commit can have. */
@TransactionalEventListener
@Transactional(propagation = Propagation.REQUIRES_NEW)
void audit(OrderPlaced event) {
jdbc.sql("insert into audit(id, note) values (?, 'written in REQUIRES_NEW')").param(event.id()).update();
}
}
@@ -0,0 +1,7 @@
package com.ankurm.events.txwrite.newtx;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication(scanBasePackages = {"com.ankurm.events.txwrite.shared", "com.ankurm.events.txwrite.newtx"})
public class NewTxApp {
}
@@ -0,0 +1,28 @@
package com.ankurm.events.txwrite.plain;
import com.ankurm.events.support.Trace;
import com.ankurm.events.support.Visibility;
import com.ankurm.events.txwrite.shared.OrderPlaced;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionalEventListener;
@Component
public class AuditPlain {
private final JdbcClient jdbc;
private final Visibility visibility;
public AuditPlain(JdbcClient jdbc, Visibility visibility) {
this.jdbc = jdbc;
this.visibility = visibility;
}
/** Writes to the database after commit, with nothing else said about transactions. */
@TransactionalEventListener
void audit(OrderPlaced event) {
jdbc.sql("insert into audit(id, note) values (?, 'written after commit')").param(event.id()).update();
Trace.add("inside the listener, right after the insert: audit row visible to other connections: %s",
visibility.count("audit", event.id()) == 1);
}
}
@@ -0,0 +1,8 @@
package com.ankurm.events.txwrite.plain;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/** The shared service plus this package's listener, and nothing from the sibling packages. */
@SpringBootApplication(scanBasePackages = {"com.ankurm.events.txwrite.shared", "com.ankurm.events.txwrite.plain", "com.ankurm.events.support"})
public class PlainApp {
}
@@ -0,0 +1,24 @@
package com.ankurm.events.txwrite.required;
import com.ankurm.events.txwrite.shared.OrderPlaced;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.event.TransactionalEventListener;
@Component
public class AuditRequired {
private final JdbcClient jdbc;
public AuditRequired(JdbcClient jdbc) {
this.jdbc = jdbc;
}
/** The obvious fix for a write that goes missing: add @Transactional. */
@TransactionalEventListener
@Transactional
void audit(OrderPlaced event) {
jdbc.sql("insert into audit(id, note) values (?, 'written in REQUIRED')").param(event.id()).update();
}
}
@@ -0,0 +1,7 @@
package com.ankurm.events.txwrite.required;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication(scanBasePackages = {"com.ankurm.events.txwrite.shared", "com.ankurm.events.txwrite.required"})
public class RequiredApp {
}
@@ -0,0 +1,4 @@
package com.ankurm.events.txwrite.shared;
public record OrderPlaced(String id) {
}
@@ -0,0 +1,24 @@
package com.ankurm.events.txwrite.shared;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderService {
private final JdbcClient jdbc;
private final ApplicationEventPublisher publisher;
public OrderService(JdbcClient jdbc, ApplicationEventPublisher publisher) {
this.jdbc = jdbc;
this.publisher = publisher;
}
@Transactional
public void place(String id) {
jdbc.sql("insert into orders(id, amount) values (?, 1)").param(id).update();
publisher.publishEvent(new OrderPlaced(id));
}
}
@@ -0,0 +1,2 @@
create table if not exists orders (id varchar(40) primary key, amount int not null);
create table if not exists audit (id varchar(40) primary key, note varchar(80) not null);
@@ -0,0 +1,99 @@
package com.ankurm.events;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.events.asyncdemo.AsyncApp;
import com.ankurm.events.asyncdemo.AsyncOrderService;
import com.ankurm.events.asyncdemo.Exploding;
import com.ankurm.events.asyncdemo.Gate;
import com.ankurm.events.asyncdemo.OrderPlaced;
import com.ankurm.events.support.Trace;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
/** Post 22, section 8: listeners on other threads. */
@ExtendWith(OutputCaptureExtension.class)
class AsyncTests {
@BeforeEach
void reset() {
Trace.drain();
((ch.qos.logback.classic.Logger) org.slf4j.LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME))
.setLevel(ch.qos.logback.classic.Level.INFO);
}
@Test
void anAsyncListenerRunsOnAnotherThreadAndDoesNotBlockThePublisher() throws Exception {
try (var t = new Transcript("09-async-listener-threads.txt",
"@Async @EventListener: the publisher moves on first; which thread the listener gets")) {
for (boolean virtual : new boolean[] {false, true}) {
t.section("spring.threads.virtual.enabled=" + virtual);
var run = BootRun.run(new String[] {"spring.threads.virtual.enabled=" + virtual}, AsyncApp.class);
var gate = run.context().getBean(Gate.class);
gate.reset();
Trace.add("publishing on %s", Trace.where());
run.context().publishEvent(new OrderPlaced("A-1"));
Trace.add("publishEvent returned, releasing the listener");
gate.publishReturned.countDown();
assertThat(gate.slowDone.await(5, TimeUnit.SECONDS)).isTrue();
var lines = Trace.drain();
lines.forEach(t::line);
run.close();
assertThat(lines).hasSize(3);
assertThat(lines.get(2)).contains(virtual ? "a virtual thread" : "platform thread");
}
t.section("the same listener in an application without @EnableAsync");
var plain = BootRun.run(new String[0], com.ankurm.events.asyncplain.AsyncPlainApp.class);
plain.context().publishEvent(new com.ankurm.events.asyncplain.OrderPlaced("A-1"));
var plainLines = Trace.drain();
plainLines.forEach(t::line);
plain.close();
assertThat(plainLines).containsExactly("@Async @EventListener: running on main");
}
}
@Test
void anAsyncListenerLeavesTheTransactionBehind() throws Exception {
try (var t = new Transcript("10-async-and-the-transaction.txt",
"Two async listeners for an event published inside a transaction")) {
var run = BootRun.run(new String[] {"spring.threads.virtual.enabled=true"}, AsyncApp.class);
var gate = run.context().getBean(Gate.class);
gate.reset();
run.context().getBean(AsyncOrderService.class).place("as-1");
assertThat(gate.commitDone.await(5, TimeUnit.SECONDS)).isTrue();
var lines = Trace.drain();
lines.forEach(t::line);
run.close();
assertThat(lines).hasSize(3);
}
}
@Test
void anExceptionInAnAsyncListenerNeverReachesThePublisher(CapturedOutput output) throws Exception {
try (var t = new Transcript("11-async-exceptions.txt",
"An @Async listener that throws: what the publisher sees, and where the exception goes")) {
var run = BootRun.run(new String[] {"logging.level.root=ERROR", "spring.threads.virtual.enabled=true"}, AsyncApp.class);
String outcome = "returned normally";
try {
run.context().publishEvent(new Exploding("A-1"));
} catch (RuntimeException e) {
outcome = "threw " + e;
}
t.line("publishEvent %s", outcome);
for (int i = 0; i < 100 && !output.getAll().contains("Unexpected exception occurred invoking async method"); i++) {
Thread.sleep(50);
}
output.getAll().lines().filter(l -> l.contains("Unexpected exception occurred invoking async method"))
.map(l -> l.substring(l.indexOf("Unexpected")))
.forEach(l -> t.line("logged: %s", l));
run.close();
assertThat(outcome).isEqualTo("returned normally");
assertThat(output.getAll()).contains("Unexpected exception occurred invoking async method");
}
}
}
@@ -0,0 +1,151 @@
package com.ankurm.events;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.events.basics.BasicsApp;
import com.ankurm.events.basics.OrderService;
import com.ankurm.events.conditional.ConditionalApp;
import com.ankurm.events.badcondition.BadConditionApp;
import com.ankurm.events.generics.Created;
import com.ankurm.events.generics.Customer;
import com.ankurm.events.generics.GenericsApp;
import com.ankurm.events.generics.Order;
import com.ankurm.events.generics.TypedCreated;
import com.ankurm.events.ordering.OrderingApp;
import com.ankurm.events.support.Modes;
import com.ankurm.events.support.Trace;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
/** Post 22, sections 1 to 4: publishing, ordering, conditions and generic events. */
class BasicsTests {
@BeforeEach
@AfterEach
void reset() {
Trace.drain();
Modes.failIn = "";
}
@Test
void publishingAnEventIsAnOrdinaryBlockingMethodCall() {
try (var t = new Transcript("01-publish-is-a-method-call.txt",
"OrderService publishes an OrderPlaced; the listeners run before publishEvent returns, on the same thread")) {
var run = BootRun.run(new String[0], BasicsApp.class);
assertThat(run.started()).isTrue();
run.context().getBean(OrderService.class).place("A-1", 42);
var lines = Trace.drain();
lines.forEach(t::line);
run.close();
assertThat(lines).hasSize(4);
assertThat(lines.get(0)).contains("publishing on main");
assertThat(lines.get(1)).contains("AuditListener.audit").contains("OrderPlaced[id=A-1, amount=42]").endsWith("on main");
assertThat(lines.get(2)).contains("AuditListener.counted");
assertThat(lines.get(3)).contains("publishEvent returned");
}
}
@Test
void orderAndChaining() {
try (var t = new Transcript("02-order-and-chaining.txt",
"Listener order, an event returned from a listener, and a listener that throws")) {
var run = BootRun.run(new String[0], OrderingApp.class);
t.section("all listeners succeed");
run.context().publishEvent(new com.ankurm.events.ordering.OrderPlaced("A-1"));
var ok = Trace.drain();
ok.forEach(t::line);
assertThat(ok).hasSize(6);
assertThat(ok.get(0)).startsWith("earliest");
assertThat(ok.get(1)).startsWith("receipt");
assertThat(ok.get(2)).startsWith("onReceipt");
assertThat(ok.get(3)).startsWith("first");
assertThat(ok.get(4)).startsWith("second");
assertThat(ok.get(5)).startsWith("unordered");
t.section("the @Order(2) listener throws");
Modes.failIn = "second";
Throwable thrown = null;
try {
run.context().publishEvent(new com.ankurm.events.ordering.OrderPlaced("A-2"));
} catch (RuntimeException e) {
thrown = e;
}
Trace.drain().forEach(t::line);
t.line("publishEvent threw: %s: %s", thrown.getClass().getSimpleName(), thrown.getMessage());
run.close();
assertThat(thrown).isInstanceOf(IllegalStateException.class);
}
}
@Test
void conditions() {
try (var t = new Transcript("03-conditional-listeners.txt",
"@EventListener(condition = ...) evaluated against the event, and a condition that names a property that does not exist")) {
var run = BootRun.run(new String[0], ConditionalApp.class);
record Case(String id, int amount) {
}
for (var c : new Case[] {new Case("VIP-1", 150), new Case("VIP-2", 20), new Case("X-3", 500), new Case("X-4", 1)}) {
t.section("OrderPlaced(" + c.id() + ", " + c.amount() + ")");
run.context().publishEvent(new com.ankurm.events.conditional.OrderPlaced(c.id(), c.amount()));
var lines = Trace.drain();
if (lines.isEmpty()) {
t.line("(no listener ran)");
}
lines.forEach(t::line);
assertThat(lines.size()).isEqualTo(switch (c.id() + c.amount()) {
case "VIP-1150" -> 3;
case "VIP-220", "X-3500" -> 1;
default -> 0;
});
}
run.close();
t.section("a condition that says #event.amountt");
var bad = BootRun.run(new String[0], BadConditionApp.class);
t.line("context started: %s", bad.started());
Throwable thrown = null;
try {
bad.context().publishEvent(new com.ankurm.events.badcondition.OrderPlaced("A-1", 500));
} catch (RuntimeException e) {
thrown = e;
}
t.line("publishEvent threw: %s", thrown == null ? "nothing" : BootRun.chain(thrown));
t.line("root cause: %s", thrown == null ? "-" : BootRun.root(thrown).getMessage());
bad.close();
assertThat(bad.started()).isTrue();
assertThat(thrown).isNotNull();
}
}
@Test
void genericEventsAndErasure() {
try (var t = new Transcript("04-generic-events-and-erasure.txt",
"Created<Order> and Created<Customer> published as plain payloads, then the same with ResolvableTypeProvider")) {
var run = BootRun.run(new String[0], GenericsApp.class);
var plainOrder = publish(t, run, "Created<Order>", new Created<>(new Order("o-1")));
var plainCustomer = publish(t, run, "Created<Customer>", new Created<>(new Customer("Asha")));
var typedOrder = publish(t, run, "TypedCreated<Order>", new TypedCreated<>(new Order("o-1")));
var typedCustomer = publish(t, run, "TypedCreated<Customer>", new TypedCreated<>(new Customer("Asha")));
run.close();
assertThat(plainOrder).containsExactly("Created<?> listener got a Order");
assertThat(plainCustomer).containsExactly("Created<?> listener got a Customer");
assertThat(typedOrder).containsExactly("TypedCreated<Order> listener got a Order");
assertThat(typedCustomer).containsExactly("TypedCreated<Customer> listener got a Customer");
}
}
private static java.util.List<String> publish(Transcript t, BootRun.Result run, String label, Object event) {
t.section("publishing " + label);
String outcome = "returned normally";
try {
run.context().publishEvent(event);
} catch (RuntimeException e) {
outcome = "threw " + BootRun.chain(e);
}
var lines = Trace.drain();
lines.forEach(t::line);
t.line("publishEvent %s", outcome);
return lines;
}
}
@@ -0,0 +1,60 @@
package com.ankurm.events;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.context.ConfigurableApplicationContext;
/** Starts a Spring Boot context without a web server and returns either the context or the failure. */
public final class BootRun {
private BootRun() {
}
public static Result run(String[] extraProperties, Class<?>... sources) {
var props = new java.util.ArrayList<String>();
props.add("spring.main.banner-mode=off");
props.add("logging.level.root=OFF");
props.addAll(java.util.List.of(extraProperties));
try {
ConfigurableApplicationContext ctx = new SpringApplicationBuilder(sources)
.web(WebApplicationType.NONE)
.properties(props.toArray(String[]::new))
.run();
return new Result(ctx, null);
} catch (RuntimeException e) {
return new Result(null, e);
}
}
/** Names the exception classes from the outside in: what a stack trace's "Caused by:" lines say, minus the noise. */
public static String chain(Throwable t) {
var names = new java.util.ArrayList<String>();
for (Throwable c = t; c != null && !names.contains(c.getClass().getSimpleName() + c.hashCode()); c = c.getCause()) {
names.add(c.getClass().getSimpleName());
if (c.getCause() == c) {
break;
}
}
return String.join(" -> ", names);
}
public static Throwable root(Throwable t) {
while (t.getCause() != null && t.getCause() != t) {
t = t.getCause();
}
return t;
}
public record Result(ConfigurableApplicationContext context, RuntimeException failure) {
public boolean started() {
return failure == null;
}
public void close() {
if (context != null) {
context.close();
}
}
}
}
@@ -0,0 +1,158 @@
package com.ankurm.events;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.events.support.Modes;
import com.ankurm.events.support.Trace;
import com.ankurm.events.tx.OrderService;
import com.ankurm.events.tx.TxApp;
import com.ankurm.events.txwrite.newtx.NewTxApp;
import com.ankurm.events.txwrite.plain.PlainApp;
import com.ankurm.events.txwrite.required.RequiredApp;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
import org.springframework.jdbc.core.simple.JdbcClient;
/** Post 22, sections 5 to 7: what a transactional listener promises, and the ways it goes quiet. */
@ExtendWith(OutputCaptureExtension.class)
class TransactionTests {
@BeforeEach
@AfterEach
void reset() {
Trace.drain();
Modes.failIn = "";
((ch.qos.logback.classic.Logger) org.slf4j.LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME))
.setLevel(ch.qos.logback.classic.Level.INFO);
}
private static int rows(BootRun.Result run, String table, String id) {
return run.context().getBean(JdbcClient.class).sql("select count(*) from " + table + " where id = ?")
.param(id).query(Integer.class).single();
}
@Test
void commitAndRollbackWithEveryPhase() {
try (var t = new Transcript("05-transaction-phases-commit-and-rollback.txt",
"The same listeners, one publisher call that commits and one that rolls back")) {
var run = BootRun.run(new String[0], TxApp.class);
var service = run.context().getBean(OrderService.class);
t.section("place(\"ok-1\", fail = false)");
service.place("ok-1", 10, false);
var committed = Trace.drain();
committed.forEach(t::line);
t.section("place(\"bad-1\", fail = true)");
Throwable thrown = null;
try {
service.place("bad-1", 10, true);
} catch (RuntimeException e) {
thrown = e;
}
var rolledBack = Trace.drain();
rolledBack.forEach(t::line);
t.line("caller saw: %s: %s", thrown.getClass().getSimpleName(), thrown.getMessage());
t.section("rows afterwards");
t.line("ok-1 rows : %d", rows(run, "orders", "ok-1"));
t.line("bad-1 rows: %d", rows(run, "orders", "bad-1"));
run.close();
assertThat(committed).anyMatch(l -> l.startsWith("AFTER_COMMIT") && l.endsWith("true"))
.noneMatch(l -> l.startsWith("AFTER_ROLLBACK"));
assertThat(committed).anyMatch(l -> l.startsWith("@EventListener") && l.endsWith("false"));
assertThat(rolledBack).anyMatch(l -> l.startsWith("AFTER_ROLLBACK"))
.noneMatch(l -> l.startsWith("AFTER_COMMIT") || l.startsWith("BEFORE_COMMIT"));
}
}
@Test
void noTransactionMeansNoTransactionalEvent() {
try (var t = new Transcript("06-no-transaction-drops-the-event.txt",
"Publishing outside any transaction: which listeners run")) {
var run = BootRun.run(new String[] {"events.fallback=true"}, TxApp.class);
run.context().getBean(OrderService.class).placeWithoutTransaction("nt-1", 5);
var lines = Trace.drain();
lines.forEach(t::line);
run.close();
assertThat(lines).hasSize(3);
assertThat(lines).anyMatch(l -> l.contains("fallbackExecution = true"));
assertThat(lines).noneMatch(l -> l.contains("tx active") && !l.startsWith("@EventListener"));
}
}
@Test
void writingFromAnAfterCommitListener() {
try (var t = new Transcript("07-after-commit-writes.txt",
"An AFTER_COMMIT listener that inserts a row: three ways to declare it")) {
t.section("no transaction attribute on the listener");
var plain = BootRun.run(new String[0], PlainApp.class);
plain.context().getBean(com.ankurm.events.txwrite.shared.OrderService.class).place("w-1");
Trace.drain().forEach(t::line);
t.line("after place() returned, order rows: %d", rows(plain, "orders", "w-1"));
t.line("after place() returned, audit rows: %d", rows(plain, "audit", "w-1"));
assertThat(rows(plain, "audit", "w-1")).isEqualTo(1);
plain.close();
t.section("@Transactional(propagation = REQUIRES_NEW) on the listener");
var newTx = BootRun.run(new String[0], NewTxApp.class);
newTx.context().getBean(com.ankurm.events.txwrite.shared.OrderService.class).place("w-2");
t.line("order rows: %d", rows(newTx, "orders", "w-2"));
t.line("audit rows: %d", rows(newTx, "audit", "w-2"));
assertThat(rows(newTx, "audit", "w-2")).isEqualTo(1);
newTx.close();
t.section("@Transactional (REQUIRED, the default) on the listener");
var required = BootRun.run(new String[0], RequiredApp.class);
t.line("context started: %s", required.started());
if (!required.started()) {
t.line("failure chain: %s", BootRun.chain(required.failure()));
t.line("root cause: %s", BootRun.root(required.failure()).getMessage());
}
assertThat(required.started()).isFalse();
assertThat(BootRun.root(required.failure()).getMessage()).contains("REQUIRES_NEW or NOT_SUPPORTED");
required.close();
}
}
@Test
void exceptionsInListenersAndTheTransaction(CapturedOutput output) {
try (var t = new Transcript("08-exceptions-and-transactions.txt",
"Which listener's exception reaches the caller, and whether the order row survives")) {
var run = BootRun.run(new String[] {"logging.level.root=ERROR"}, TxApp.class);
var service = run.context().getBean(OrderService.class);
int n = 0;
for (String failing : new String[] {"plain", "beforeCommit", "afterCommit"}) {
String id = "ex-" + (++n);
Modes.failIn = failing;
Throwable thrown = null;
try {
service.place(id, 1, false);
} catch (RuntimeException e) {
thrown = e;
}
Trace.drain();
t.section(failing + " listener throws");
t.line("caller saw: %s", thrown == null ? "nothing" : thrown.getClass().getSimpleName() + ": " + thrown.getMessage());
t.line("order row survived: %s", rows(run, "orders", id) == 1);
assertThat(thrown != null).isEqualTo(!failing.equals("afterCommit"));
assertThat(rows(run, "orders", id) == 1).isEqualTo(failing.equals("afterCommit"));
if (failing.equals("afterCommit")) {
var logged = output.getAll().lines().toList();
for (int k = 0; k < logged.size(); k++) {
if (logged.get(k).contains("TransactionSynchronization.afterCompletion threw exception")) {
t.line("logged: %s", logged.get(k).substring(logged.get(k).indexOf(" : ") + 3));
t.line("logged: %s", logged.get(k + 2));
break;
}
}
}
}
run.close();
}
}
}
@@ -0,0 +1,54 @@
package com.ankurm.events;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* Writes a numbered transcript under {@code output/} and echoes it to the console.
* Every console block quoted in the article comes out of one of these files verbatim.
*/
public final class Transcript implements AutoCloseable {
private final Path path;
private final StringWriter buffer = new StringWriter();
private final PrintWriter out = new PrintWriter(buffer);
public Transcript(String fileName, String title) {
this.path = Path.of("output", fileName);
out.println("# " + title);
out.println();
}
public Transcript line(String format, Object... args) {
out.println(args.length == 0 ? format : String.format(format, args));
return this;
}
public Transcript blank() {
out.println();
return this;
}
public Transcript section(String heading) {
out.println();
out.println("--- " + heading + " ---");
return this;
}
@Override
public void close() {
out.flush();
// Absolute paths of whoever ran the build are environment noise, not a finding.
String text = buffer.toString().replace(System.getProperty("user.dir"), "<core-events>");
try {
Files.createDirectories(path.getParent());
Files.writeString(path, text);
} catch (IOException e) {
throw new IllegalStateException("could not write " + path, e);
}
System.out.print(text);
}
}