spring-projects / spring-projects/spring-framework

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

Open
#35,081 3 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

in: data status: waiting-for-triage
Dominant language
Java
Stars
60.2k
Forks
38.8k
Avg merge
5d 2h
Merged PRs (30d)
27

Description

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)

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Run the provided regression scenario and trace TransactionalEventListener handling for TransactionPhase.BEFORE_COMMIT when events are published through ApplicationEventPublisher during TransactionTemplate commit. Add or adjust a regression test so EventA leads to EventB and EventC, then verify the test passes.

Written by the indexing model from the issue text.

Assessment

Tech stack
java, spring
Domain
backend
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Stale
Clarity
Clearly specified
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.