mirror of
https://github.com/spring-projects/spring-framework.git
synced 2026-09-30 03:09:03 +00:00
Document combining @Retryable with proxy-based features
Add a "Combining @Retryable with Other Proxy-Based Features" section to the resilience reference documentation, covering interaction with @Transactional, @Cacheable, and @Async, as well as advice order customization between @Retryable and @Async via @EnableResilientMethods(order) and @EnableAsync(order). Also document the advice chain semantics in @Retryable Javadoc, add cross-reference TIP blocks in the @Async, @Cacheable, and @Transactional reference sections, add a @Cacheable combination test to RetryInterceptorTests, and add RetryableTransactionTests in spring-tx for the @Transactional combination. See gh-35584 Closes gh-37005 Signed-off-by: jhan0121 <jhan0121@gmail.com>
This commit is contained in:
@@ -115,6 +115,141 @@ whereas the caller of the `@Retryable` method will only ever see the last except
|
||||
====
|
||||
|
||||
|
||||
[[resilience-annotations-retryable-combining]]
|
||||
=== Combining `@Retryable` with Other Proxy-Based Features
|
||||
|
||||
Spring AOP applies interceptors in a specific order when multiple annotations such as
|
||||
`@Retryable`, `@Transactional`, `@Cacheable`, and `@Async` are present on the same method.
|
||||
The resulting advice chain determines how retries interact with each feature, and
|
||||
understanding that chain is important for using `@Retryable` correctly in combination with
|
||||
other annotations.
|
||||
|
||||
[[resilience-annotations-retryable-combining-transactional]]
|
||||
==== With `@Transactional`
|
||||
|
||||
When `@Transactional` and `@Retryable` are used together, the advice chain is:
|
||||
|
||||
----
|
||||
Retry (OUTER) → Transaction (INNER) → target method
|
||||
----
|
||||
|
||||
Each retry attempt starts a fresh transaction. If the target method throws, the transaction
|
||||
is rolled back and `@Retryable` decides whether to retry. On success, the transaction
|
||||
commits. This is usually the desired behavior for transient failures such as database
|
||||
deadlocks.
|
||||
|
||||
[source,java,indent=0,subs="verbatim,quotes"]
|
||||
----
|
||||
@Transactional
|
||||
@Retryable(TransientDataAccessException.class)
|
||||
public void updateRecord() {
|
||||
// Each retry runs in its own transaction
|
||||
}
|
||||
----
|
||||
|
||||
[NOTE]
|
||||
====
|
||||
Because the retry interceptor is outside the transaction interceptor, the current
|
||||
transaction has already been rolled back by the time the retry interceptor receives the
|
||||
exception. The retry interceptor sees the same, unwrapped exception that the target
|
||||
method threw.
|
||||
====
|
||||
|
||||
[[resilience-annotations-retryable-combining-cacheable]]
|
||||
==== With `@Cacheable`
|
||||
|
||||
When `@Cacheable` and `@Retryable` are used together, the advice chain is:
|
||||
|
||||
----
|
||||
Retry (OUTER) → Cache (INNER) → target method
|
||||
----
|
||||
|
||||
The cache interceptor runs on every attempt. If the cache is populated between attempts
|
||||
(for example, by a concurrent request), subsequent retry attempts will return the cached
|
||||
value without invoking the target method. On success, the cache is populated as normal.
|
||||
|
||||
[source,java,indent=0,subs="verbatim,quotes"]
|
||||
----
|
||||
@Cacheable("items")
|
||||
@Retryable
|
||||
public Item loadItem(String id) {
|
||||
// Retry wraps the cache lookup; each attempt checks the cache first
|
||||
}
|
||||
----
|
||||
|
||||
[[resilience-annotations-retryable-combining-async]]
|
||||
==== With `@Async`
|
||||
|
||||
When `@Async` and `@Retryable` are used together, the advice chain is:
|
||||
|
||||
----
|
||||
Async (OUTER) → Retry (INNER) → target method
|
||||
----
|
||||
|
||||
The method is submitted to the async executor once, and all retry attempts run on the
|
||||
same async thread. The caller receives a `CompletableFuture` or `Future` that completes
|
||||
when the last retry attempt finishes (either with a result or a final exception).
|
||||
|
||||
[source,java,indent=0,subs="verbatim,quotes"]
|
||||
----
|
||||
@Async
|
||||
@Retryable
|
||||
public CompletableFuture<String> fetchData() {
|
||||
// Retries happen on the async thread, not the calling thread
|
||||
}
|
||||
----
|
||||
|
||||
[NOTE]
|
||||
====
|
||||
Because `@Async` is outermost, the calling thread is never blocked by retry delays.
|
||||
All retry attempts, including any configured delay between them, happen on the async
|
||||
executor thread.
|
||||
====
|
||||
|
||||
[[resilience-annotations-retryable-combining-order]]
|
||||
==== Adjusting Advice Order
|
||||
|
||||
The `@Async` ordering described above reflects the relative `order` of the
|
||||
`RetryAnnotationBeanPostProcessor` (registered by `@EnableResilientMethods`) and the
|
||||
`AsyncAnnotationBeanPostProcessor` (registered by `@EnableAsync`). Both are plain
|
||||
`Ordered` bean post-processors, so you can change their relative ordering by setting the
|
||||
`order` attribute on `@EnableResilientMethods` and/or `@EnableAsync`.
|
||||
|
||||
[source,java,indent=0,subs="verbatim,quotes"]
|
||||
----
|
||||
@Configuration
|
||||
@EnableResilientMethods(order = Ordered.LOWEST_PRECEDENCE) // <1>
|
||||
@EnableAsync(order = Ordered.LOWEST_PRECEDENCE - 1) // <2>
|
||||
class AppConfig {
|
||||
}
|
||||
----
|
||||
<1> Raises the retry post-processor's order so that it runs after the async
|
||||
post-processor.
|
||||
<2> Lowers the async post-processor's order so that it runs before the retry
|
||||
post-processor. As a result, retry becomes the outermost advice and async the
|
||||
innermost, reversing the default order.
|
||||
|
||||
[NOTE]
|
||||
====
|
||||
With the reversed order shown above, exceptions thrown during asynchronous execution are
|
||||
not retried: the retry interceptor only sees the `Future` handle, which is returned
|
||||
immediately, rather than the outcome of the asynchronous invocation. Such an arrangement
|
||||
only retries synchronous submission failures (for example, a rejected task submission)
|
||||
and is rarely desirable in practice.
|
||||
====
|
||||
|
||||
[NOTE]
|
||||
====
|
||||
This technique does not apply to `@Transactional` or `@Cacheable`. Their advisors are
|
||||
registered through Spring's shared `InfrastructureAdvisorAutoProxyCreator`, whose own
|
||||
post-processor `order` is fixed at `Ordered.HIGHEST_PRECEDENCE` and is unaffected by the
|
||||
`order` attribute on `@EnableTransactionManagement` or `@EnableCaching` (that attribute
|
||||
only affects ordering relative to other advisors on the same proxy). As a result, retry
|
||||
advice is always applied outside `@Transactional` and `@Cacheable`, regardless of the
|
||||
`order` configured on `@EnableResilientMethods`.
|
||||
====
|
||||
|
||||
|
||||
[[resilience-annotations-concurrencylimit]]
|
||||
== `@ConcurrencyLimit`
|
||||
|
||||
|
||||
@@ -167,6 +167,11 @@ Reactive Streams cancellation signals. See the
|
||||
xref:data-access/transaction/programmatic.adoc#tx-prog-operator-cancel[Cancel Signals]
|
||||
section under "Using the TransactionalOperator" for more details.
|
||||
|
||||
TIP: When `@Transactional` is combined with `@Retryable`, the retry advice is applied
|
||||
outermost, so each retry attempt runs in its own transaction. See
|
||||
xref:core/resilience.adoc#resilience-annotations-retryable-combining-transactional[Combining `@Retryable` with `@Transactional`]
|
||||
for details.
|
||||
|
||||
[[transaction-declarative-annotations-method-visibility]]
|
||||
.Method visibility and `@Transactional` in proxy mode
|
||||
[NOTE]
|
||||
|
||||
@@ -44,6 +44,11 @@ The following example uses `@Cacheable` on the `findBook` method with multiple c
|
||||
public Book findBook(ISBN isbn) {...}
|
||||
----
|
||||
|
||||
TIP: When `@Cacheable` is combined with `@Retryable`, the retry advice is applied
|
||||
outermost, so each retry attempt checks the cache before invoking the method. See
|
||||
xref:core/resilience.adoc#resilience-annotations-retryable-combining-cacheable[Combining `@Retryable` with `@Cacheable`]
|
||||
for details.
|
||||
|
||||
[[cache-annotations-cacheable-default-key]]
|
||||
=== Default Key Generation
|
||||
|
||||
|
||||
@@ -573,6 +573,11 @@ for asynchronous execution in the first place, not externally re-declared to be
|
||||
However, you can manually set up Spring's `AsyncExecutionInterceptor` with Spring AOP,
|
||||
in combination with a custom pointcut.
|
||||
|
||||
TIP: When `@Async` is combined with `@Retryable`, the async advice is applied outermost, so
|
||||
all retry attempts run on the async executor thread. See
|
||||
xref:core/resilience.adoc#resilience-annotations-retryable-combining-async[Combining `@Retryable` with `@Async`]
|
||||
for details.
|
||||
|
||||
|
||||
[[scheduling-annotation-support-qualification]]
|
||||
=== Executor Qualification with `@Async`
|
||||
|
||||
@@ -42,6 +42,16 @@ import org.springframework.resilience.retry.MethodRetryPredicate;
|
||||
* <p>Inspired by the <a href="https://github.com/spring-projects/spring-retry">Spring Retry</a>
|
||||
* project but redesigned as a minimal core retry feature in the Spring Framework.
|
||||
*
|
||||
* <p>When combined with other proxy-based annotations such as
|
||||
* {@code @Transactional}, {@code @Cacheable}, or {@code @Async}, the order of the
|
||||
* resulting advice chain affects retry semantics. By default, {@code @Retryable} advice
|
||||
* is applied outside {@code @Transactional} and {@code @Cacheable} (each retry attempt
|
||||
* starts a fresh transaction or checks the cache), but inside {@code @Async} (all retry
|
||||
* attempts run on the async executor thread). The order relative to {@code @Async} advice
|
||||
* can be customized via {@link EnableResilientMethods#order()} and
|
||||
* {@link org.springframework.scheduling.annotation.EnableAsync#order()}, whereas the
|
||||
* order relative to {@code @Transactional} and {@code @Cacheable} advice is fixed.
|
||||
*
|
||||
* @author Juergen Hoeller
|
||||
* @author Sam Brannen
|
||||
* @since 7.0
|
||||
|
||||
+51
@@ -28,6 +28,7 @@ import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import org.aopalliance.intercept.MethodInterceptor;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -39,6 +40,12 @@ import org.springframework.aop.interceptor.SimpleTraceInterceptor;
|
||||
import org.springframework.aop.support.AopUtils;
|
||||
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
|
||||
import org.springframework.beans.factory.support.RootBeanDefinition;
|
||||
import org.springframework.cache.Cache;
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.cache.annotation.EnableCaching;
|
||||
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
|
||||
import org.springframework.cache.interceptor.SimpleKey;
|
||||
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
|
||||
import org.springframework.context.support.GenericApplicationContext;
|
||||
import org.springframework.core.env.PropertiesPropertySource;
|
||||
@@ -61,6 +68,7 @@ import static org.assertj.core.api.Assertions.assertThatRuntimeException;
|
||||
/**
|
||||
* @author Juergen Hoeller
|
||||
* @author Sam Brannen
|
||||
* @author Juhwan Lee
|
||||
* @since 7.0
|
||||
*/
|
||||
class RetryInterceptorTests {
|
||||
@@ -315,6 +323,25 @@ class RetryInterceptorTests {
|
||||
assertThat(target.counter).hasValue(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void withCacheableAnnotation() {
|
||||
AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext();
|
||||
ctx.registerBeanDefinition("bean", new RootBeanDefinition(CacheableAnnotatedBean.class));
|
||||
ctx.registerBeanDefinition("cacheManager", new RootBeanDefinition(ConcurrentMapCacheManager.class));
|
||||
ctx.registerBeanDefinition("config", new RootBeanDefinition(EnablingConfigWithCaching.class));
|
||||
ctx.refresh();
|
||||
CacheableAnnotatedBean proxy = ctx.getBean(CacheableAnnotatedBean.class);
|
||||
CacheableAnnotatedBean target = (CacheableAnnotatedBean) AopProxyUtils.getSingletonTarget(proxy);
|
||||
target.cache = ctx.getBean(CacheManager.class).getCache("tests");
|
||||
|
||||
// Simulates the cache being concurrently populated between retry attempts.
|
||||
String result = proxy.retryOperation();
|
||||
assertThat(result).isEqualTo("concurrently cached value");
|
||||
// Only invoked once: the 2nd attempt found the cached value instead of
|
||||
// invoking the target again, proving that retry wraps the cache check.
|
||||
assertThat(target.counter).hasValue(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void withMethodRetryEventListener() throws Exception {
|
||||
AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext();
|
||||
@@ -639,6 +666,24 @@ class RetryInterceptorTests {
|
||||
}
|
||||
|
||||
|
||||
static class CacheableAnnotatedBean {
|
||||
|
||||
AtomicInteger counter = new AtomicInteger();
|
||||
|
||||
@Nullable Cache cache;
|
||||
|
||||
@Cacheable("tests")
|
||||
@Retryable(maxRetries = 2, delay = 10)
|
||||
public String retryOperation() {
|
||||
if (counter.incrementAndGet() == 1) {
|
||||
this.cache.put(SimpleKey.EMPTY, "concurrently cached value");
|
||||
throw new IllegalStateException();
|
||||
}
|
||||
return "result";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@EnableResilientMethods
|
||||
static class EnablingConfig {
|
||||
}
|
||||
@@ -649,4 +694,10 @@ class RetryInterceptorTests {
|
||||
static class EnablingConfigWithAsync {
|
||||
}
|
||||
|
||||
|
||||
@EnableCaching
|
||||
@EnableResilientMethods
|
||||
static class EnablingConfigWithCaching {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* 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.annotation;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.aop.framework.AopProxyUtils;
|
||||
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.resilience.annotation.EnableResilientMethods;
|
||||
import org.springframework.resilience.annotation.Retryable;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.testfixture.CallCountingTransactionManager;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Tests for combining {@link Retryable @Retryable} with
|
||||
* {@link Transactional @Transactional}.
|
||||
*
|
||||
* @author Juhwan Lee
|
||||
* @since 7.1
|
||||
*/
|
||||
class RetryableTransactionTests {
|
||||
|
||||
@Test
|
||||
void withTransactionalAnnotation() {
|
||||
AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(Config.class);
|
||||
TransactionalAnnotatedBean proxy = ctx.getBean(TransactionalAnnotatedBean.class);
|
||||
TransactionalAnnotatedBean target = (TransactionalAnnotatedBean) AopProxyUtils.getSingletonTarget(proxy);
|
||||
CallCountingTransactionManager txManager = ctx.getBean(CallCountingTransactionManager.class);
|
||||
|
||||
// 2 = 1 initial invocation + 1 retry attempt
|
||||
String result = proxy.retryOperation();
|
||||
assertThat(result).isEqualTo("result");
|
||||
assertThat(target.counter).hasValue(2);
|
||||
// 1 rollback for the initial failure, 1 commit for the successful retry
|
||||
assertThat(txManager.rollbacks).isEqualTo(1);
|
||||
assertThat(txManager.commits).isEqualTo(1);
|
||||
|
||||
ctx.close();
|
||||
}
|
||||
|
||||
|
||||
static class TransactionalAnnotatedBean {
|
||||
|
||||
AtomicInteger counter = new AtomicInteger();
|
||||
|
||||
@Transactional
|
||||
@Retryable(maxRetries = 2, delay = 10)
|
||||
public String retryOperation() {
|
||||
if (counter.incrementAndGet() < 2) {
|
||||
throw new IllegalStateException();
|
||||
}
|
||||
return "result";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Configuration
|
||||
@EnableTransactionManagement
|
||||
@EnableResilientMethods
|
||||
static class Config {
|
||||
|
||||
@Bean
|
||||
TransactionalAnnotatedBean transactionalAnnotatedBean() {
|
||||
return new TransactionalAnnotatedBean();
|
||||
}
|
||||
|
||||
@Bean
|
||||
PlatformTransactionManager transactionManager() {
|
||||
return new CallCountingTransactionManager();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user