diff --git a/spring-tx/src/main/java/org/springframework/transaction/interceptor/MethodRollbackEvent.java b/spring-tx/src/main/java/org/springframework/transaction/interceptor/MethodRollbackEvent.java new file mode 100644 index 00000000000..73696145547 --- /dev/null +++ b/spring-tx/src/main/java/org/springframework/transaction/interceptor/MethodRollbackEvent.java @@ -0,0 +1,76 @@ +/* + * Copyright 2002-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.transaction.interceptor; + +import org.aopalliance.intercept.MethodInvocation; + +import org.springframework.context.event.MethodFailureEvent; +import org.springframework.transaction.TransactionExecution; + +/** + * Event published for every exception encountered that triggers a transaction rollback + * through a proxy-triggered method invocation or a reactive publisher returned from it. + * Can be listened to via an {@code ApplicationListener} bean or + * an {@code @EventListener(MethodRollbackEvent.class)} method. + * + *

Note: This event gets published right before the actual transaction rollback. + * As a consequence, the exposed {@link #getTransaction() transaction} reflects the state + * of the transaction right before the rollback. + * + * @author Juergen Hoeller + * @since 7.0.3 + * @see TransactionInterceptor + * @see org.springframework.transaction.annotation.Transactional + * @see org.springframework.context.ApplicationListener + * @see org.springframework.context.event.EventListener + */ +@SuppressWarnings("serial") +public class MethodRollbackEvent extends MethodFailureEvent { + + private final TransactionExecution transaction; + + + /** + * Create a new event for the given rolled-back method invocation. + * @param invocation the transactional method invocation + * @param failure the exception encountered that triggered a rollback + * @param transaction the transaction status right before the rollback + */ + public MethodRollbackEvent(MethodInvocation invocation, Throwable failure, TransactionExecution transaction) { + super(invocation, failure); + this.transaction = transaction; + } + + + /** + * Return the exception encountered. + *

This may be an exception thrown by the method or emitted by the + * reactive publisher returned from the method. + */ + @Override + public Throwable getFailure() { + return super.getFailure(); + } + + /** + * Return the corresponding transaction status. + */ + public TransactionExecution getTransaction() { + return this.transaction; + } + +} diff --git a/spring-tx/src/main/java/org/springframework/transaction/interceptor/TransactionAspectSupport.java b/spring-tx/src/main/java/org/springframework/transaction/interceptor/TransactionAspectSupport.java index 5daaebe40ec..d2b9fdbb98d 100644 --- a/spring-tx/src/main/java/org/springframework/transaction/interceptor/TransactionAspectSupport.java +++ b/spring-tx/src/main/java/org/springframework/transaction/interceptor/TransactionAspectSupport.java @@ -44,6 +44,7 @@ import org.springframework.transaction.NoTransactionException; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.ReactiveTransaction; import org.springframework.transaction.ReactiveTransactionManager; +import org.springframework.transaction.TransactionExecution; import org.springframework.transaction.TransactionManager; import org.springframework.transaction.TransactionStatus; import org.springframework.transaction.TransactionSystemException; @@ -371,7 +372,7 @@ public abstract class TransactionAspectSupport implements BeanFactoryAware, Init } catch (Throwable ex) { // target invocation exception - completeTransactionAfterThrowing(txInfo, ex); + completeTransactionAfterThrowing(txInfo, invocation, ex); throw ex; } finally { @@ -389,6 +390,7 @@ public abstract class TransactionAspectSupport implements BeanFactoryAware, Init Throwable cause = ex.getCause(); Assert.state(cause != null, "Cause must not be null"); if (txAttr.rollbackOn(cause)) { + invocation.onRollback(cause, status); status.setRollbackOnly(); } } @@ -398,7 +400,7 @@ public abstract class TransactionAspectSupport implements BeanFactoryAware, Init } else if (VAVR_PRESENT && VavrDelegate.isVavrTry(retVal)) { // Set rollback-only in case of Vavr failure matching our rollback rules... - retVal = VavrDelegate.evaluateTryFailure(retVal, txAttr, status); + retVal = VavrDelegate.evaluateTryFailure(invocation, retVal, txAttr, status); } } } @@ -419,12 +421,13 @@ public abstract class TransactionAspectSupport implements BeanFactoryAware, Init Object retVal = invocation.proceedWithInvocation(); if (retVal != null && VAVR_PRESENT && VavrDelegate.isVavrTry(retVal)) { // Set rollback-only in case of Vavr failure matching our rollback rules... - retVal = VavrDelegate.evaluateTryFailure(retVal, txAttr, status); + retVal = VavrDelegate.evaluateTryFailure(invocation, retVal, txAttr, status); } return retVal; } catch (Throwable ex) { if (txAttr.rollbackOn(ex)) { + invocation.onRollback(ex, status); // A RuntimeException: will lead to a rollback. if (ex instanceof RuntimeException runtimeException) { throw runtimeException; @@ -691,13 +694,16 @@ public abstract class TransactionAspectSupport implements BeanFactoryAware, Init * @param txInfo information about the current transaction * @param ex throwable encountered */ - protected void completeTransactionAfterThrowing(@Nullable TransactionInfo txInfo, Throwable ex) { + protected void completeTransactionAfterThrowing( + @Nullable TransactionInfo txInfo, InvocationCallback invocation, Throwable ex) { + if (txInfo != null && txInfo.getTransactionStatus() != null) { if (logger.isTraceEnabled()) { logger.trace("Completing transaction for [" + txInfo.getJoinpointIdentification() + "] after exception: " + ex); } if (txInfo.transactionAttribute != null && txInfo.transactionAttribute.rollbackOn(ex)) { + invocation.onRollback(ex, txInfo.getTransactionStatus()); try { txInfo.getTransactionManager().rollback(txInfo.getTransactionStatus()); } @@ -826,7 +832,21 @@ public abstract class TransactionAspectSupport implements BeanFactoryAware, Init @FunctionalInterface protected interface InvocationCallback { + /** + * Invocation adaptation method. + * @see org.aopalliance.intercept.MethodInvocation#proceed() + */ @Nullable Object proceedWithInvocation() throws Throwable; + + /** + * Callback method for rollback-triggering exceptions. + * @param failure the application exception that triggered the rollback + * @param execution the current transaction status + * @since 7.0.3 + * @see TransactionAttribute#rollbackOn(Throwable) + */ + default void onRollback(Throwable failure, TransactionExecution execution) { + } } @@ -868,9 +888,12 @@ public abstract class TransactionAspectSupport implements BeanFactoryAware, Init return (retVal instanceof Try); } - public static Object evaluateTryFailure(Object retVal, TransactionAttribute txAttr, TransactionStatus status) { + public static Object evaluateTryFailure( + InvocationCallback invocation, Object retVal, TransactionAttribute txAttr, TransactionStatus status) { + return ((Try) retVal).onFailure(ex -> { if (txAttr.rollbackOn(ex)) { + invocation.onRollback(ex, status); status.setRollbackOnly(); } }); @@ -911,7 +934,7 @@ public abstract class TransactionAspectSupport implements BeanFactoryAware, Init } }, this::commitTransactionAfterReturning, - this::completeTransactionAfterThrowing, + (txInfo, ex) -> completeTransactionAfterThrowing(txInfo, invocation, ex), this::rollbackTransactionOnCancel) .onErrorMap(this::unwrapIfResourceCleanupFailure)) .contextWrite(TransactionContextManager.getOrCreateContext()) @@ -931,7 +954,7 @@ public abstract class TransactionAspectSupport implements BeanFactoryAware, Init } }, this::commitTransactionAfterReturning, - this::completeTransactionAfterThrowing, + (txInfo, ex) -> completeTransactionAfterThrowing(txInfo, invocation, ex), this::rollbackTransactionOnCancel) .onErrorMap(this::unwrapIfResourceCleanupFailure)) .contextWrite(TransactionContextManager.getOrCreateContext()) @@ -1003,13 +1026,16 @@ public abstract class TransactionAspectSupport implements BeanFactoryAware, Init return Mono.empty(); } - private Mono completeTransactionAfterThrowing(@Nullable ReactiveTransactionInfo txInfo, Throwable ex) { + private Mono completeTransactionAfterThrowing( + @Nullable ReactiveTransactionInfo txInfo, InvocationCallback invocation, Throwable ex) { + if (txInfo != null && txInfo.getReactiveTransaction() != null) { if (logger.isTraceEnabled()) { logger.trace("Completing transaction for [" + txInfo.getJoinpointIdentification() + "] after exception: " + ex); } if (txInfo.transactionAttribute != null && txInfo.transactionAttribute.rollbackOn(ex)) { + invocation.onRollback(ex, txInfo.getReactiveTransaction()); return txInfo.getTransactionManager().rollback(txInfo.getReactiveTransaction()).onErrorMap(ex2 -> { logger.error("Application exception overridden by rollback exception", ex); if (ex2 instanceof TransactionSystemException systemException) { diff --git a/spring-tx/src/main/java/org/springframework/transaction/interceptor/TransactionInterceptor.java b/spring-tx/src/main/java/org/springframework/transaction/interceptor/TransactionInterceptor.java index fe9e2cff00a..1882dee89f1 100644 --- a/spring-tx/src/main/java/org/springframework/transaction/interceptor/TransactionInterceptor.java +++ b/spring-tx/src/main/java/org/springframework/transaction/interceptor/TransactionInterceptor.java @@ -28,7 +28,10 @@ import org.jspecify.annotations.Nullable; import org.springframework.aop.support.AopUtils; import org.springframework.beans.factory.BeanFactory; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ApplicationEventPublisherAware; import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionExecution; import org.springframework.transaction.TransactionManager; /** @@ -52,7 +55,11 @@ import org.springframework.transaction.TransactionManager; * @see org.springframework.aop.framework.ProxyFactory */ @SuppressWarnings("serial") -public class TransactionInterceptor extends TransactionAspectSupport implements MethodInterceptor, Serializable { +public class TransactionInterceptor extends TransactionAspectSupport + implements MethodInterceptor, ApplicationEventPublisherAware, Serializable { + + private @Nullable ApplicationEventPublisher applicationEventPublisher; + /** * Create a new TransactionInterceptor. @@ -107,6 +114,11 @@ public class TransactionInterceptor extends TransactionAspectSupport implements } + @Override + public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) { + this.applicationEventPublisher = applicationEventPublisher; + } + @Override public @Nullable Object invoke(MethodInvocation invocation) throws Throwable { // Work out the target class: may be {@code null}. @@ -115,7 +127,27 @@ public class TransactionInterceptor extends TransactionAspectSupport implements Class targetClass = (invocation.getThis() != null ? AopUtils.getTargetClass(invocation.getThis()) : null); // Adapt to TransactionAspectSupport's invokeWithinTransaction... - return invokeWithinTransaction(invocation.getMethod(), targetClass, invocation::proceed); + return invokeWithinTransaction(invocation.getMethod(), targetClass, new InvocationCallback() { + @Override + public @Nullable Object proceedWithInvocation() throws Throwable { + return invocation.proceed(); + } + @Override + public void onRollback(Throwable failure, TransactionExecution execution) { + MethodRollbackEvent event = new MethodRollbackEvent(invocation, failure, execution); + logger.trace(event, failure); + if (applicationEventPublisher != null) { + try { + applicationEventPublisher.publishEvent(event); + } + catch (Throwable ex) { + if (logger.isWarnEnabled()) { + logger.warn("Failed to publish " + event, ex); + } + } + } + } + }); } diff --git a/spring-tx/src/test/java/org/springframework/transaction/annotation/EnableTransactionManagementTests.java b/spring-tx/src/test/java/org/springframework/transaction/annotation/EnableTransactionManagementTests.java index 2a69a9fe95f..bc9553026e2 100644 --- a/spring-tx/src/test/java/org/springframework/transaction/annotation/EnableTransactionManagementTests.java +++ b/spring-tx/src/test/java/org/springframework/transaction/annotation/EnableTransactionManagementTests.java @@ -16,7 +16,10 @@ package org.springframework.transaction.annotation; +import java.lang.reflect.Method; +import java.util.ArrayList; import java.util.Collection; +import java.util.List; import java.util.Map; import java.util.Properties; @@ -26,6 +29,7 @@ import org.springframework.aop.support.AopUtils; import org.springframework.beans.factory.NoUniqueBeanDefinitionException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.ApplicationListener; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.annotation.AdviceMode; import org.springframework.context.annotation.AnnotationConfigApplicationContext; @@ -43,8 +47,10 @@ import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.TransactionManager; import org.springframework.transaction.config.TransactionManagementConfigUtils; import org.springframework.transaction.event.TransactionalEventListenerFactory; +import org.springframework.transaction.interceptor.MethodRollbackEvent; import org.springframework.transaction.interceptor.TransactionAttribute; import org.springframework.transaction.testfixture.CallCountingTransactionManager; +import org.springframework.util.ClassUtils; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatException; @@ -358,31 +364,55 @@ class EnableTransactionManagementTests { } @Test - void gh23473AppliesToRuntimeExceptionOnly() { - AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(Gh23473ConfigA.class); + void gh23473AppliesToRuntimeExceptionOnly() throws Exception { + AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(); + ctx.register(Gh23473ConfigA.class); + MethodRollbackEventListener listener = new MethodRollbackEventListener(); + ctx.addApplicationListener(listener); + ctx.refresh(); TestServiceWithRollback bean = ctx.getBean("testBean", TestServiceWithRollback.class); CallCountingTransactionManager txManager = ctx.getBean(CallCountingTransactionManager.class); + Method method1 = TestServiceWithRollback.class.getMethod("methodOne"); + Method method2 = TestServiceWithRollback.class.getMethod("methodTwo"); assertThatException().isThrownBy(bean::methodOne); assertThatException().isThrownBy(bean::methodTwo); assertThat(txManager.begun).isEqualTo(2); assertThat(txManager.commits).isEqualTo(2); assertThat(txManager.rollbacks).isEqualTo(0); + assertThat(listener.events).isEmpty(); ctx.close(); } @Test - void gh23473AppliesRollbackOnAnyException() { - AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(Gh23473ConfigB.class); + void gh23473AppliesRollbackOnAnyException() throws Exception { + AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(); + ctx.register(Gh23473ConfigB.class); + MethodRollbackEventListener listener = new MethodRollbackEventListener(); + ctx.addApplicationListener(listener); + ctx.refresh(); TestServiceWithRollback bean = ctx.getBean("testBean", TestServiceWithRollback.class); CallCountingTransactionManager txManager = ctx.getBean(CallCountingTransactionManager.class); + Method method1 = TestServiceWithRollback.class.getMethod("methodOne"); + Method method2 = TestServiceWithRollback.class.getMethod("methodTwo"); assertThatException().isThrownBy(bean::methodOne); assertThatException().isThrownBy(bean::methodTwo); assertThat(txManager.begun).isEqualTo(2); assertThat(txManager.commits).isEqualTo(0); assertThat(txManager.rollbacks).isEqualTo(2); + assertThat(listener.events).hasSize(2); + assertThat(listener.events.get(0)) + .satisfies(event -> assertThat(event.getMethod()).isEqualTo(method1)) + .satisfies(event -> assertThat(event.getFailure()).isExactlyInstanceOf(Exception.class)) + .satisfies(event -> assertThat(event.getTransaction().getTransactionName()) + .isEqualTo(ClassUtils.getQualifiedMethodName(method1))); + assertThat(listener.events.get(1)) + .satisfies(event -> assertThat(event.getMethod()).isEqualTo(method2)) + .satisfies(event -> assertThat(event.getFailure()).isExactlyInstanceOf(Exception.class)) + .satisfies(event -> assertThat(event.getTransaction().getTransactionName()) + .isEqualTo(ClassUtils.getQualifiedMethodName(method2))); ctx.close(); } @@ -759,6 +789,17 @@ class EnableTransactionManagementTests { } + static class MethodRollbackEventListener implements ApplicationListener { + + public final List events = new ArrayList<>(); + + @Override + public void onApplicationEvent(MethodRollbackEvent event) { + this.events.add(event); + } + } + + @Configuration @EnableTransactionManagement static class Gh23473ConfigA {