From 7c82d76fc4d7f3880c199a34af0adc14222f82eb Mon Sep 17 00:00:00 2001 From: jhan0121 Date: Sun, 5 Jul 2026 03:10:23 +0900 Subject: [PATCH] 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 and @Transactional reference sections, add a @Cacheable combination test to RetryInterceptorTests, and add RetryableTransactionTests in spring-tx for the @Transactional combination. Closes gh-35584 Signed-off-by: jhan0121 --- .../modules/ROOT/pages/core/resilience.adoc | 135 ++++++++++++++++++ .../transaction/declarative/annotations.adoc | 5 + .../ROOT/pages/integration/scheduling.adoc | 5 + .../resilience/annotation/Retryable.java | 10 ++ .../resilience/RetryInterceptorTests.java | 126 ++++++++++++++++ .../annotation/RetryableTransactionTests.java | 93 ++++++++++++ 6 files changed, 374 insertions(+) create mode 100644 spring-tx/src/test/java/org/springframework/transaction/annotation/RetryableTransactionTests.java diff --git a/framework-docs/modules/ROOT/pages/core/resilience.adoc b/framework-docs/modules/ROOT/pages/core/resilience.adoc index fa68ac4fd432..d8010416f468 100644 --- a/framework-docs/modules/ROOT/pages/core/resilience.adoc +++ b/framework-docs/modules/ROOT/pages/core/resilience.adoc @@ -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 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` diff --git a/framework-docs/modules/ROOT/pages/data-access/transaction/declarative/annotations.adoc b/framework-docs/modules/ROOT/pages/data-access/transaction/declarative/annotations.adoc index 50b6c5e7bb89..4c3b5a468fc9 100644 --- a/framework-docs/modules/ROOT/pages/data-access/transaction/declarative/annotations.adoc +++ b/framework-docs/modules/ROOT/pages/data-access/transaction/declarative/annotations.adoc @@ -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] diff --git a/framework-docs/modules/ROOT/pages/integration/scheduling.adoc b/framework-docs/modules/ROOT/pages/integration/scheduling.adoc index 76d8606348d7..1b925a59df06 100644 --- a/framework-docs/modules/ROOT/pages/integration/scheduling.adoc +++ b/framework-docs/modules/ROOT/pages/integration/scheduling.adoc @@ -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` diff --git a/spring-context/src/main/java/org/springframework/resilience/annotation/Retryable.java b/spring-context/src/main/java/org/springframework/resilience/annotation/Retryable.java index 9f929febcdb9..2678c287811c 100644 --- a/spring-context/src/main/java/org/springframework/resilience/annotation/Retryable.java +++ b/spring-context/src/main/java/org/springframework/resilience/annotation/Retryable.java @@ -42,6 +42,16 @@ *

Inspired by the Spring Retry * project but redesigned as a minimal core retry feature in the Spring Framework. * + *

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 diff --git a/spring-context/src/test/java/org/springframework/resilience/RetryInterceptorTests.java b/spring-context/src/test/java/org/springframework/resilience/RetryInterceptorTests.java index 78d968f73331..43cd54fc0493 100644 --- a/spring-context/src/test/java/org/springframework/resilience/RetryInterceptorTests.java +++ b/spring-context/src/test/java/org/springframework/resilience/RetryInterceptorTests.java @@ -22,12 +22,15 @@ import java.nio.charset.MalformedInputException; import java.nio.file.AccessDeniedException; import java.time.Duration; +import java.util.Collection; import java.util.Properties; +import java.util.concurrent.Callable; import java.util.concurrent.CompletableFuture; 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 +42,11 @@ 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.context.annotation.AnnotationConfigApplicationContext; import org.springframework.context.support.GenericApplicationContext; import org.springframework.core.env.PropertiesPropertySource; @@ -61,6 +69,7 @@ /** * @author Juergen Hoeller * @author Sam Brannen + * @author Juhwan Lee * @since 7.0 */ class RetryInterceptorTests { @@ -315,6 +324,31 @@ void withAsyncAnnotation() { assertThat(target.counter).hasValue(3); } + @Test + void withCacheableAnnotation() { + AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(); + ctx.registerBeanDefinition("bean", new RootBeanDefinition(CacheableAnnotatedBean.class)); + ctx.registerBeanDefinition("cacheManager", new RootBeanDefinition(CountingCacheManager.class)); + ctx.registerBeanDefinition("config", new RootBeanDefinition(EnablingConfigWithCaching.class)); + ctx.refresh(); + CacheableAnnotatedBean proxy = ctx.getBean(CacheableAnnotatedBean.class); + CacheableAnnotatedBean target = (CacheableAnnotatedBean) AopProxyUtils.getSingletonTarget(proxy); + CountingCacheManager cacheManager = ctx.getBean(CountingCacheManager.class); + + // 2 = 1 initial invocation + 1 retry attempt + String result = proxy.retryOperation(); + assertThat(result).isEqualTo("result"); + assertThat(target.counter).hasValue(2); + // 2 = cache checked on each retry attempt; proves retry is outermost + assertThat(cacheManager.gets).isEqualTo(2); + + // 2 = cache hit; target not invoked on second call + assertThat(proxy.retryOperation()).isEqualTo("result"); + assertThat(target.counter).hasValue(2); + // 3 = one more cache check for the cache hit + assertThat(cacheManager.gets).isEqualTo(3); + } + @Test void withMethodRetryEventListener() throws Exception { AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(); @@ -639,6 +673,92 @@ public CompletableFuture retryOperation() { } + static class CacheableAnnotatedBean { + + AtomicInteger counter = new AtomicInteger(); + + @Cacheable("tests") + @Retryable(maxRetries = 2, delay = 10) + public String retryOperation() { + if (counter.incrementAndGet() < 2) { + throw new IllegalStateException(); + } + return "result"; + } + } + + + static class CountingCacheManager implements CacheManager { + + int gets; + + private final ConcurrentMapCacheManager delegate = new ConcurrentMapCacheManager(); + + @Override + public @Nullable Cache getCache(String name) { + Cache cache = this.delegate.getCache(name); + return (cache != null ? new CountingCache(cache) : null); + } + + @Override + public Collection getCacheNames() { + return this.delegate.getCacheNames(); + } + + class CountingCache implements Cache { + + private final Cache delegate; + + CountingCache(Cache delegate) { + this.delegate = delegate; + } + + @Override + public String getName() { + return this.delegate.getName(); + } + + @Override + public Object getNativeCache() { + return this.delegate.getNativeCache(); + } + + @Override + public @Nullable ValueWrapper get(Object key) { + gets++; + return this.delegate.get(key); + } + + @Override + public @Nullable T get(Object key, @Nullable Class type) { + gets++; + return this.delegate.get(key, type); + } + + @Override + public T get(Object key, Callable valueLoader) { + gets++; + return this.delegate.get(key, valueLoader); + } + + @Override + public void put(Object key, @Nullable Object value) { + this.delegate.put(key, value); + } + + @Override + public void evict(Object key) { + this.delegate.evict(key); + } + + @Override + public void clear() { + this.delegate.clear(); + } + } + } + + @EnableResilientMethods static class EnablingConfig { } @@ -649,4 +769,10 @@ static class EnablingConfig { static class EnablingConfigWithAsync { } + + @EnableCaching + @EnableResilientMethods + static class EnablingConfigWithCaching { + } + } diff --git a/spring-tx/src/test/java/org/springframework/transaction/annotation/RetryableTransactionTests.java b/spring-tx/src/test/java/org/springframework/transaction/annotation/RetryableTransactionTests.java new file mode 100644 index 000000000000..703e59765115 --- /dev/null +++ b/spring-tx/src/test/java/org/springframework/transaction/annotation/RetryableTransactionTests.java @@ -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(); + } + } + +}