Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions framework-docs/modules/ROOT/pages/core/resilience.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
5 changes: 5 additions & 0 deletions framework-docs/modules/ROOT/pages/integration/scheduling.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,16 @@
* <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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
Expand All @@ -61,6 +69,7 @@
/**
* @author Juergen Hoeller
* @author Sam Brannen
* @author Juhwan Lee
* @since 7.0
*/
class RetryInterceptorTests {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -639,6 +673,92 @@ public CompletableFuture<Void> 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<String> 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 <T> @Nullable T get(Object key, @Nullable Class<T> type) {
gets++;
return this.delegate.get(key, type);
}

@Override
public <T> T get(Object key, Callable<T> 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 {
}
Expand All @@ -649,4 +769,10 @@ static class EnablingConfig {
static class EnablingConfigWithAsync {
}


@EnableCaching
@EnableResilientMethods
static class EnablingConfigWithCaching {
}

}
Loading