Prevent empty declaration of @⁠ConcurrencyLimit

As a follow-up to gh-35461 and a comment left on the Spring Blog, we
have decided to prevent empty declarations of @⁠ConcurrencyLimit,
thereby requiring users to explicitly declare the value for the limit.

Closes gh-35523
This commit is contained in:
Sam Brannen 2025-09-22 18:18:53 +02:00
parent 8b254ad25e
commit 5ac3c40689
3 changed files with 110 additions and 30 deletions

View File

@ -64,25 +64,30 @@ public @interface ConcurrencyLimit {
* @see #limitString() * @see #limitString()
*/ */
@AliasFor("limit") @AliasFor("limit")
int value() default 1; int value() default Integer.MIN_VALUE;
/** /**
* The applicable concurrency limit: 1 by default, * The concurrency limit.
* effectively locking the target instance for each method invocation. * <p>Specify {@code 1} to effectively lock the target instance for each method
* <p>Specify a limit higher than 1 for pool-like throttling, constraining * invocation.
* <p>Specify a limit greater than {@code 1} for pool-like throttling, constraining
* the number of concurrent invocations similar to the upper bound of a pool. * the number of concurrent invocations similar to the upper bound of a pool.
* <p>Specify {@code -1} for unbounded concurrency.
* @see #value() * @see #value()
* @see #limitString() * @see #limitString()
* @see org.springframework.util.ConcurrencyThrottleSupport#UNBOUNDED_CONCURRENCY
*/ */
@AliasFor("value") @AliasFor("value")
int limit() default 1; int limit() default Integer.MIN_VALUE;
/** /**
* The concurrency limit, as a configurable String. * The concurrency limit, as a configurable String.
* <p>A non-empty value specified here overrides the {@link #limit()} (or * <p>A non-empty value specified here overrides the {@link #limit()} and
* {@link #value()}) attribute. * {@link #value()} attributes.
* <p>This supports Spring-style "${...}" placeholders as well as SpEL expressions. * <p>This supports Spring-style "${...}" placeholders as well as SpEL expressions.
* <p>See the Javadoc for {@link #limit()} for details on supported values.
* @see #limit() * @see #limit()
* @see org.springframework.util.ConcurrencyThrottleSupport#UNBOUNDED_CONCURRENCY
*/ */
String limitString() default ""; String limitString() default "";

View File

@ -108,6 +108,9 @@ public class ConcurrencyLimitBeanPostProcessor extends AbstractBeanFactoryAwareA
if (interceptor == null) { if (interceptor == null) {
Assert.state(annotation != null, "No @ConcurrencyLimit annotation found"); Assert.state(annotation != null, "No @ConcurrencyLimit annotation found");
int concurrencyLimit = parseInt(annotation.limit(), annotation.limitString()); int concurrencyLimit = parseInt(annotation.limit(), annotation.limitString());
if (concurrencyLimit < -1) {
throw new IllegalStateException(annotation + " must be configured with a valid limit");
}
interceptor = new ConcurrencyThrottleInterceptor(concurrencyLimit); interceptor = new ConcurrencyThrottleInterceptor(concurrencyLimit);
if (!perMethod) { if (!perMethod) {
cache.classInterceptor = interceptor; cache.classInterceptor = interceptor;

View File

@ -35,10 +35,13 @@ import org.springframework.resilience.annotation.ConcurrencyLimitBeanPostProcess
import org.springframework.resilience.annotation.EnableResilientMethods; import org.springframework.resilience.annotation.EnableResilientMethods;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
/** /**
* @author Juergen Hoeller * @author Juergen Hoeller
* @author Hyunsang Han * @author Hyunsang Han
* @author Sam Brannen
* @since 7.0 * @since 7.0
*/ */
class ConcurrencyLimitTests { class ConcurrencyLimitTests {
@ -61,12 +64,7 @@ class ConcurrencyLimitTests {
@Test @Test
void withPostProcessorForMethod() { void withPostProcessorForMethod() {
DefaultListableBeanFactory bf = new DefaultListableBeanFactory(); AnnotatedMethodBean proxy = createProxy(AnnotatedMethodBean.class);
bf.registerBeanDefinition("bean", new RootBeanDefinition(AnnotatedMethodBean.class));
ConcurrencyLimitBeanPostProcessor bpp = new ConcurrencyLimitBeanPostProcessor();
bpp.setBeanFactory(bf);
bf.addBeanPostProcessor(bpp);
AnnotatedMethodBean proxy = bf.getBean(AnnotatedMethodBean.class);
AnnotatedMethodBean target = (AnnotatedMethodBean) AopProxyUtils.getSingletonTarget(proxy); AnnotatedMethodBean target = (AnnotatedMethodBean) AopProxyUtils.getSingletonTarget(proxy);
List<CompletableFuture<?>> futures = new ArrayList<>(10); List<CompletableFuture<?>> futures = new ArrayList<>(10);
@ -77,14 +75,22 @@ class ConcurrencyLimitTests {
assertThat(target.current).hasValue(0); assertThat(target.current).hasValue(0);
} }
@Test
void withPostProcessorForMethodWithUnboundedConcurrency() {
AnnotatedMethodBean proxy = createProxy(AnnotatedMethodBean.class);
AnnotatedMethodBean target = (AnnotatedMethodBean) AopProxyUtils.getSingletonTarget(proxy);
List<CompletableFuture<?>> futures = new ArrayList<>(10);
for (int i = 0; i < 10; i++) {
futures.add(CompletableFuture.runAsync(proxy::unboundedConcurrency));
}
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
assertThat(target.current).hasValue(10);
}
@Test @Test
void withPostProcessorForClass() { void withPostProcessorForClass() {
DefaultListableBeanFactory bf = new DefaultListableBeanFactory(); AnnotatedClassBean proxy = createProxy(AnnotatedClassBean.class);
bf.registerBeanDefinition("bean", new RootBeanDefinition(AnnotatedClassBean.class));
ConcurrencyLimitBeanPostProcessor bpp = new ConcurrencyLimitBeanPostProcessor();
bpp.setBeanFactory(bf);
bf.addBeanPostProcessor(bpp);
AnnotatedClassBean proxy = bf.getBean(AnnotatedClassBean.class);
AnnotatedClassBean target = (AnnotatedClassBean) AopProxyUtils.getSingletonTarget(proxy); AnnotatedClassBean target = (AnnotatedClassBean) AopProxyUtils.getSingletonTarget(proxy);
List<CompletableFuture<?>> futures = new ArrayList<>(30); List<CompletableFuture<?>> futures = new ArrayList<>(30);
@ -122,17 +128,52 @@ class ConcurrencyLimitTests {
ctx.close(); ctx.close();
} }
@Test
void configurationErrors() {
ConfigurationErrorsBean proxy = createProxy(ConfigurationErrorsBean.class);
assertThatIllegalStateException()
.isThrownBy(proxy::emptyDeclaration)
.withMessageMatching("@.+?ConcurrencyLimit(.+?) must be configured with a valid limit")
.withMessageContaining("\"\"")
.withMessageContaining(String.valueOf(Integer.MIN_VALUE));
assertThatIllegalStateException()
.isThrownBy(proxy::negative42Int)
.withMessageMatching("@.+?ConcurrencyLimit(.+?) must be configured with a valid limit")
.withMessageContaining("-42");
assertThatIllegalStateException()
.isThrownBy(proxy::negative42String)
.withMessageMatching("@.+?ConcurrencyLimit(.+?) must be configured with a valid limit")
.withMessageContaining("-42");
assertThatExceptionOfType(NumberFormatException.class)
.isThrownBy(proxy::alphanumericString)
.withMessageContaining("B2");
}
private static <T> T createProxy(Class<T> beanClass) {
DefaultListableBeanFactory bf = new DefaultListableBeanFactory();
bf.registerBeanDefinition("bean", new RootBeanDefinition(beanClass));
ConcurrencyLimitBeanPostProcessor bpp = new ConcurrencyLimitBeanPostProcessor();
bpp.setBeanFactory(bf);
bf.addBeanPostProcessor(bpp);
return bf.getBean(beanClass);
}
static class NonAnnotatedBean { static class NonAnnotatedBean {
AtomicInteger counter = new AtomicInteger(); final AtomicInteger counter = new AtomicInteger();
public void concurrentOperation() { public void concurrentOperation() {
if (counter.incrementAndGet() > 2) { if (counter.incrementAndGet() > 2) {
throw new IllegalStateException(); throw new IllegalStateException();
} }
try { try {
Thread.sleep(100); Thread.sleep(10);
} }
catch (InterruptedException ex) { catch (InterruptedException ex) {
throw new IllegalStateException(ex); throw new IllegalStateException(ex);
@ -144,7 +185,7 @@ class ConcurrencyLimitTests {
static class AnnotatedMethodBean { static class AnnotatedMethodBean {
AtomicInteger current = new AtomicInteger(); final AtomicInteger current = new AtomicInteger();
@ConcurrencyLimit(2) @ConcurrencyLimit(2)
public void concurrentOperation() { public void concurrentOperation() {
@ -152,29 +193,40 @@ class ConcurrencyLimitTests {
throw new IllegalStateException(); throw new IllegalStateException();
} }
try { try {
Thread.sleep(100); Thread.sleep(10);
} }
catch (InterruptedException ex) { catch (InterruptedException ex) {
throw new IllegalStateException(ex); throw new IllegalStateException(ex);
} }
current.decrementAndGet(); current.decrementAndGet();
} }
@ConcurrencyLimit(limit = -1)
public void unboundedConcurrency() {
current.incrementAndGet();
try {
Thread.sleep(10);
}
catch (InterruptedException ex) {
throw new IllegalStateException(ex);
}
}
} }
@ConcurrencyLimit(2) @ConcurrencyLimit(2)
static class AnnotatedClassBean { static class AnnotatedClassBean {
AtomicInteger current = new AtomicInteger(); final AtomicInteger current = new AtomicInteger();
AtomicInteger currentOverride = new AtomicInteger(); final AtomicInteger currentOverride = new AtomicInteger();
public void concurrentOperation() { public void concurrentOperation() {
if (current.incrementAndGet() > 2) { if (current.incrementAndGet() > 2) {
throw new IllegalStateException(); throw new IllegalStateException();
} }
try { try {
Thread.sleep(100); Thread.sleep(10);
} }
catch (InterruptedException ex) { catch (InterruptedException ex) {
throw new IllegalStateException(ex); throw new IllegalStateException(ex);
@ -187,7 +239,7 @@ class ConcurrencyLimitTests {
throw new IllegalStateException(); throw new IllegalStateException();
} }
try { try {
Thread.sleep(100); Thread.sleep(10);
} }
catch (InterruptedException ex) { catch (InterruptedException ex) {
throw new IllegalStateException(ex); throw new IllegalStateException(ex);
@ -201,7 +253,7 @@ class ConcurrencyLimitTests {
throw new IllegalStateException(); throw new IllegalStateException();
} }
try { try {
Thread.sleep(100); Thread.sleep(10);
} }
catch (InterruptedException ex) { catch (InterruptedException ex) {
throw new IllegalStateException(ex); throw new IllegalStateException(ex);
@ -218,7 +270,7 @@ class ConcurrencyLimitTests {
static class PlaceholderBean { static class PlaceholderBean {
AtomicInteger current = new AtomicInteger(); final AtomicInteger current = new AtomicInteger();
@ConcurrencyLimit(limitString = "${test.concurrency.limit}") @ConcurrencyLimit(limitString = "${test.concurrency.limit}")
public void concurrentOperation() { public void concurrentOperation() {
@ -226,7 +278,7 @@ class ConcurrencyLimitTests {
throw new IllegalStateException(); throw new IllegalStateException();
} }
try { try {
Thread.sleep(100); Thread.sleep(10);
} }
catch (InterruptedException ex) { catch (InterruptedException ex) {
throw new IllegalStateException(ex); throw new IllegalStateException(ex);
@ -235,4 +287,24 @@ class ConcurrencyLimitTests {
} }
} }
static class ConfigurationErrorsBean {
@ConcurrencyLimit
public void emptyDeclaration() {
}
@ConcurrencyLimit(-42)
public void negative42Int() {
}
@ConcurrencyLimit(limitString = "-42")
public void negative42String() {
}
@ConcurrencyLimit(limitString = "B2")
public void alphanumericString() {
}
}
} }