Skip to content

TransactionalEventListener(phase = BEFORE_COMMIT) not called if the event was fired by another transactional listener in the same BEFORE_COMMIT phase #35081

Open
@Nimelrian

Description

@Nimelrian

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
Loading

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
Loading

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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    in: dataIssues in data modules (jdbc, orm, oxm, tx)status: waiting-for-triageAn issue we've not yet triaged or decided on

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions