Description
An event published within the handling of a transactional event listener with its phase set to BEFORE_COMMIT
will NOT be handled by another transactional event listener which is also assigned to the BEFORE_COMMIT
phase.
Expected
If a BEFORE_COMMIT
listener publishes an event listened to by another BEFORE_COMMIT
listener, that event listener should get called in the already running before commit phase.
sequenceDiagram;
participant Component
participant EventPublisher
participant Transaction
participant A as Listener A
participant B as Listener B
Component-->>+Transaction: start
Component-->>EventPublisher: publish event A;
EventPublisher-->>Transaction: defer listener A execution to commit;
Component-->>Transaction: commit
Transaction-->>A: handle event
A-->>EventPublisher: publish event B;
EventPublisher-->>Transaction: defer listener B execution to commit;
Transaction-->>B: handle event
Transaction-->>-Component: committed
Actual
If a BEFORE_COMMIT
listener publishes an event listened to by another BEFORE_COMMIT
listener, that event listener does not get called and the event is "lost".
sequenceDiagram;
participant Component
participant EventPublisher
participant Transaction
participant A as Listener A
participant B as Listener B
Component-->>+Transaction: start
Component-->>EventPublisher: publish event A;
EventPublisher-->>Transaction: defer listener A execution to commit;
Component-->>Transaction: commit
Transaction-->>A: handle event
A-->>EventPublisher: publish event B;
EventPublisher-->>Transaction: defer listener B execution to commit;
Transaction-->>-Component: committed
Affected versions
Tested with Spring Boot 3.5 and 3.4. Both show the same behavior.
Reproduction
Small Spring Boot test for reproduction.
Maven POM:
<?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>3.5.0</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>de.nimelrian</groupId>
<artifactId>before-commit-reproduction</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>21</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>
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureJdbc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.annotation.Configuration;
import org.springframework.test.context.event.ApplicationEvents;
import org.springframework.test.context.event.RecordApplicationEvents;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
import org.springframework.transaction.support.TransactionTemplate;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
@AutoConfigureJdbc
@RecordApplicationEvents
class DemoApplicationTests {
private static final Logger log = LoggerFactory.getLogger(DemoApplicationTests.class);
@Test
void while_committing_additionally_published_events_should_be_handled(
@Autowired TransactionTemplate txTemplate,
@Autowired ApplicationEventPublisher applicationEventPublisher,
@Autowired ApplicationEvents applicationEvents
) {
txTemplate.executeWithoutResult(tx -> {
log.info("Publishing EventA");
applicationEventPublisher.publishEvent(new EventA());
log.info("Committing transaction");
});
assertThat(applicationEvents.stream(EventA.class)).as("EventA should have been published").hasSize(1);
assertThat(applicationEvents.stream(EventB.class)).as("EventB should have been published by listener for EventA").hasSize(1);
assertThat(applicationEvents.stream(EventC.class)).as("EventC should have been published by listener for EventB").hasSize(1); // This fails
}
@Configuration
static class TestContextConfiguration {
private final ApplicationEventPublisher applicationEventPublisher;
TestContextConfiguration(ApplicationEventPublisher applicationEventPublisher) {
this.applicationEventPublisher = applicationEventPublisher;
}
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
void handleEventA(EventA eventA) {
log.info("handleEventA");
applicationEventPublisher.publishEvent(new EventB());
}
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
void handleEventB(EventB eventB) {
log.info("handleEventB");
applicationEventPublisher.publishEvent(new EventC());
}
}
record EventA() {
}
record EventB() {
}
record EventC() {
}
}
Log output:
INFO [demo] [ main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Starting...
INFO [demo] [ main] com.zaxxer.hikari.pool.HikariPool : HikariPool-1 - Added connection conn0: url=jdbc:h2:mem:9e2b7744-82f9-47aa-a3b3-5b866c6a8e06 user=SA
INFO [demo] [ main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Start completed.
INFO [demo] [ main] de.nimelrian.demo.DemoApplicationTests : Publishing EventA
INFO [demo] [ main] de.nimelrian.demo.DemoApplicationTests : Committing transaction
INFO [demo] [ main] de.nimelrian.demo.DemoApplicationTests : handleEventA
java.lang.AssertionError: [EventC should have been published by listener for EventB]
Expected size: 1 but was: 0 in:
[]
at de.nimelrian.demo.DemoApplicationTests.while_committing_additionally_published_events_should_be_handled(DemoApplicationTests.java:40)
at java.base/java.lang.reflect.Method.invoke(Method.java:580)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)
Comment From: Nimelrian
Note: This probably occurs due to TransactionSynchronizationManager.getSynchronizations
returning an UnmodifiableList of the listeners for events (in form of synchronizations with a beforeCommit
handler) which have been published at the start of the beforeCommit triggers: https://github.com/spring-projects/spring-framework/blob/v6.2.8/spring-tx/src/main/java/org/springframework/transaction/support/TransactionSynchronizationUtils.java#L125-L129
public static void triggerBeforeCommit(boolean readOnly) {
for (TransactionSynchronization synchronization : TransactionSynchronizationManager.getSynchronizations()) {
synchronization.beforeCommit(readOnly);
}
}
If events get published during the execution of listeners they get registered as new synchronizations, but the foreach loop obviously doesn't take these into account since the list with synchronizations is only created once at the start of the loop. Maybe this could instead make use of a queue from which synchronizations are popped and executed (with new synchronizations being emplaced at the back) until none are left...