From 5867512fabf97194c125d9cce8f29de3bd01a205 Mon Sep 17 00:00:00 2001 From: Hyunwoo Gu Date: Mon, 1 Jun 2026 11:01:45 +0900 Subject: [PATCH] Introduce immediate attribute on @CacheEvict By default, @CacheEvict delegates to Cache#clear / Cache#evict, which the Cache contract allows to be performed asynchronously or in a deferred fashion. With a Redis-backed cache, for example, Cache#clear may translate to an asynchronous UNLINK that can later remove an entry written by a concurrent @CachePut. The new immediate attribute delegates to Cache#invalidate / Cache#evictIfPresent instead, expecting all affected entries to be invisible for subsequent lookups as soon as the operation returns. beforeInvocation=true continues to imply immediate eviction. This also aligns the call site with the existing immediate parameter on AbstractCacheInvoker#doEvict / #doClear, which CacheAspectSupport had been driving from isBeforeInvocation(), conflating scheduling with visibility. Closes gh-36806 Signed-off-by: Hyunwoo Gu --- .../aspectj/AbstractCacheAnnotationTests.java | 56 +++++++++++++++++++ .../AnnotatedClassCacheableService.java | 10 ++++ .../cache/config/CacheableService.java | 4 ++ .../cache/config/DefaultCacheableService.java | 10 ++++ .../cache/annotation/CacheEvict.java | 25 +++++++++ .../SpringCacheAnnotationParser.java | 1 + .../cache/config/CacheAdviceParser.java | 5 ++ .../cache/interceptor/CacheAspectSupport.java | 6 +- .../interceptor/CacheEvictOperation.java | 15 +++++ .../cache/config/spring-cache.xsd | 8 +++ .../cache/config/cache-advice.xml | 4 ++ .../cache/AbstractCacheAnnotationTests.java | 56 +++++++++++++++++++ .../beans/AnnotatedClassCacheableService.java | 10 ++++ .../cache/beans/CacheableService.java | 4 ++ .../cache/beans/DefaultCacheableService.java | 10 ++++ 15 files changed, 222 insertions(+), 2 deletions(-) diff --git a/spring-aspects/src/test/java/org/springframework/cache/aspectj/AbstractCacheAnnotationTests.java b/spring-aspects/src/test/java/org/springframework/cache/aspectj/AbstractCacheAnnotationTests.java index f5fadec2d154..ec0944f6e15e 100644 --- a/spring-aspects/src/test/java/org/springframework/cache/aspectj/AbstractCacheAnnotationTests.java +++ b/spring-aspects/src/test/java/org/springframework/cache/aspectj/AbstractCacheAnnotationTests.java @@ -182,6 +182,20 @@ protected void testEvictEarly(CacheableService service) { assertThat(r2).isNotSameAs(r1); } + protected void testEvictImmediate(CacheableService service) { + Cache cache = this.cm.getCache("testCache"); + + Object o1 = new Object(); + cache.putIfAbsent(o1, -1L); + Object r1 = service.cache(o1); + + service.evictImmediate(o1); + assertThat(cache.get(o1)).isNull(); + + Object r2 = service.cache(o1); + assertThat(r2).isNotSameAs(r1); + } + protected void testEvictException(CacheableService service) { Object o1 = new Object(); Object r1 = service.cache(o1); @@ -281,6 +295,28 @@ protected void testEvictAllEarly(CacheableService service) { assertThat(r4).isNotSameAs(r2); } + protected void testEvictAllImmediate(CacheableService service) { + Cache cache = this.cm.getCache("testCache"); + + Object o1 = new Object(); + Object o2 = new Object(); + cache.putIfAbsent(o1, -1L); + cache.putIfAbsent(o2, -2L); + + Object r1 = service.cache(o1); + Object r2 = service.cache(o2); + assertThat(r2).isNotSameAs(r1); + + service.evictAllImmediate(new Object()); + assertThat(cache.get(o1)).isNull(); + assertThat(cache.get(o2)).isNull(); + + Object r3 = service.cache(o1); + Object r4 = service.cache(o2); + assertThat(r3).isNotSameAs(r1); + assertThat(r4).isNotSameAs(r2); + } + protected void testConditionalExpression(CacheableService service) { Object r1 = service.conditional(4); Object r2 = service.conditional(4); @@ -586,6 +622,11 @@ void evictEarly() { testEvictEarly(this.cs); } + @Test + void evictImmediate() { + testEvictImmediate(this.cs); + } + @Test void evictWithException() { testEvictException(this.cs); @@ -601,6 +642,11 @@ void evictAllEarly() { testEvictAllEarly(this.cs); } + @Test + void evictAllImmediate() { + testEvictAllImmediate(this.cs); + } + @Test void evictWithKey() { testEvictWithKey(this.cs); @@ -656,11 +702,21 @@ void classEvictEarly() { testEvictEarly(this.ccs); } + @Test + void classEvictImmediate() { + testEvictImmediate(this.ccs); + } + @Test void classEvictAll() { testEvictAll(this.ccs, true); } + @Test + void classEvictAllImmediate() { + testEvictAllImmediate(this.ccs); + } + @Test void classEvictWithException() { testEvictException(this.ccs); diff --git a/spring-aspects/src/test/java/org/springframework/cache/config/AnnotatedClassCacheableService.java b/spring-aspects/src/test/java/org/springframework/cache/config/AnnotatedClassCacheableService.java index f86274ad59a8..0c6465e8f4e4 100644 --- a/spring-aspects/src/test/java/org/springframework/cache/config/AnnotatedClassCacheableService.java +++ b/spring-aspects/src/test/java/org/springframework/cache/config/AnnotatedClassCacheableService.java @@ -96,6 +96,11 @@ public void evictEarly(Object arg1) { throw new RuntimeException("exception thrown - evict should still occur"); } + @Override + @CacheEvict(cacheNames = "testCache", key = "#p0", immediate = true) + public void evictImmediate(Object arg1) { + } + @Override @CacheEvict(cacheNames = "testCache", allEntries = true) public void evictAll(Object arg1) { @@ -107,6 +112,11 @@ public void evictAllEarly(Object arg1) { throw new RuntimeException("exception thrown - evict should still occur"); } + @Override + @CacheEvict(cacheNames = "testCache", allEntries = true, immediate = true) + public void evictAllImmediate(Object arg1) { + } + @Override @Cacheable(cacheNames = "testCache", key = "#p0") public Object key(Object arg1, Object arg2) { diff --git a/spring-aspects/src/test/java/org/springframework/cache/config/CacheableService.java b/spring-aspects/src/test/java/org/springframework/cache/config/CacheableService.java index 1f56fe3a31fc..378c22d01d1c 100644 --- a/spring-aspects/src/test/java/org/springframework/cache/config/CacheableService.java +++ b/spring-aspects/src/test/java/org/springframework/cache/config/CacheableService.java @@ -43,10 +43,14 @@ public interface CacheableService { void evictEarly(Object arg1); + void evictImmediate(Object arg1); + void evictAll(Object arg1); void evictAllEarly(Object arg1); + void evictAllImmediate(Object arg1); + T conditional(int field); T conditionalSync(int field); diff --git a/spring-aspects/src/test/java/org/springframework/cache/config/DefaultCacheableService.java b/spring-aspects/src/test/java/org/springframework/cache/config/DefaultCacheableService.java index 7ee51944583a..ab207378af03 100644 --- a/spring-aspects/src/test/java/org/springframework/cache/config/DefaultCacheableService.java +++ b/spring-aspects/src/test/java/org/springframework/cache/config/DefaultCacheableService.java @@ -83,6 +83,11 @@ public void evictEarly(Object arg1) { throw new RuntimeException("exception thrown - evict should still occur"); } + @Override + @CacheEvict(cacheNames = "testCache", key = "#p0", immediate = true) + public void evictImmediate(Object arg1) { + } + @Override @CacheEvict(cacheNames = "testCache", allEntries = true) public void evictAll(Object arg1) { @@ -94,6 +99,11 @@ public void evictAllEarly(Object arg1) { throw new RuntimeException("exception thrown - evict should still occur"); } + @Override + @CacheEvict(cacheNames = "testCache", allEntries = true, immediate = true) + public void evictAllImmediate(Object arg1) { + } + @Override @Cacheable(cacheNames = "testCache", condition = "#p0 == 3") public Long conditional(int classField) { diff --git a/spring-context/src/main/java/org/springframework/cache/annotation/CacheEvict.java b/spring-context/src/main/java/org/springframework/cache/annotation/CacheEvict.java index a85b2fef2bf5..a28657a4420f 100644 --- a/spring-context/src/main/java/org/springframework/cache/annotation/CacheEvict.java +++ b/spring-context/src/main/java/org/springframework/cache/annotation/CacheEvict.java @@ -154,4 +154,29 @@ */ boolean beforeInvocation() default false; + /** + * Whether the eviction must be performed immediately, with all affected + * entries expected to be invisible to subsequent lookups as soon as this + * operation returns. + *

Setting this attribute to {@code true} causes the framework to invoke + * {@link org.springframework.cache.Cache#evictIfPresent} (for a single key) + * or {@link org.springframework.cache.Cache#invalidate} (when + * {@link #allEntries} is {@code true}) instead of + * {@link org.springframework.cache.Cache#evict} / + * {@link org.springframework.cache.Cache#clear}. The latter pair is allowed + * by contract to perform the removal in an asynchronous or deferred fashion, + * which can lead to a concurrently inserted entry being removed by a late + * eviction — for example, when a Redis-backed cache implements + * {@link org.springframework.cache.Cache#clear} via an asynchronous + * {@code UNLINK} while a subsequent {@code @CachePut} writes a new value. + *

Defaults to {@code false}, preserving the previous behavior where the + * immediacy of the eviction implicitly follows {@link #beforeInvocation()}: + * a before-invocation eviction is always immediate, whereas an + * after-invocation eviction may be deferred by the underlying cache + * implementation. + * @since 7.0 + * @see org.springframework.cache.Cache#evictIfPresent + * @see org.springframework.cache.Cache#invalidate + */ + boolean immediate() default false; } diff --git a/spring-context/src/main/java/org/springframework/cache/annotation/SpringCacheAnnotationParser.java b/spring-context/src/main/java/org/springframework/cache/annotation/SpringCacheAnnotationParser.java index 0078c045ddf1..82b1ce9f3836 100644 --- a/spring-context/src/main/java/org/springframework/cache/annotation/SpringCacheAnnotationParser.java +++ b/spring-context/src/main/java/org/springframework/cache/annotation/SpringCacheAnnotationParser.java @@ -140,6 +140,7 @@ private CacheEvictOperation parseEvictAnnotation( builder.setCacheResolver(cacheEvict.cacheResolver()); builder.setCacheWide(cacheEvict.allEntries()); builder.setBeforeInvocation(cacheEvict.beforeInvocation()); + builder.setImmediate(cacheEvict.immediate()); defaultConfig.applyDefault(builder); CacheEvictOperation op = builder.build(); diff --git a/spring-context/src/main/java/org/springframework/cache/config/CacheAdviceParser.java b/spring-context/src/main/java/org/springframework/cache/config/CacheAdviceParser.java index f4c3c3175bb3..876a822d19fa 100644 --- a/spring-context/src/main/java/org/springframework/cache/config/CacheAdviceParser.java +++ b/spring-context/src/main/java/org/springframework/cache/config/CacheAdviceParser.java @@ -136,6 +136,11 @@ private RootBeanDefinition parseDefinitionSource(Element definition, ParserConte builder.setBeforeInvocation(Boolean.parseBoolean(after.trim())); } + String immediate = opElement.getAttribute("immediate"); + if (StringUtils.hasText(immediate)) { + builder.setImmediate(Boolean.parseBoolean(immediate.trim())); + } + Collection col = cacheOpMap.computeIfAbsent(nameHolder, key -> new ArrayList<>(2)); col.add(builder.build()); } diff --git a/spring-context/src/main/java/org/springframework/cache/interceptor/CacheAspectSupport.java b/spring-context/src/main/java/org/springframework/cache/interceptor/CacheAspectSupport.java index 2b2bd0110ad7..9714200f41ff 100644 --- a/spring-context/src/main/java/org/springframework/cache/interceptor/CacheAspectSupport.java +++ b/spring-context/src/main/java/org/springframework/cache/interceptor/CacheAspectSupport.java @@ -674,17 +674,19 @@ private void performCacheEvicts(List contexts, @Nullable CacheEvictOperation operation = (CacheEvictOperation) context.metadata.operation; if (isConditionPassing(context, result)) { Object key = context.getGeneratedKey(); + // A before-invocation eviction is always immediate (backwards-compatible default). + boolean immediate = operation.isImmediate() || operation.isBeforeInvocation(); for (Cache cache : context.getCaches()) { if (operation.isCacheWide()) { logInvalidating(context, operation, null); - doClear(cache, operation.isBeforeInvocation()); + doClear(cache, immediate); } else { if (key == null) { key = generateKey(context, result); } logInvalidating(context, operation, key); - doEvict(cache, key, operation.isBeforeInvocation()); + doEvict(cache, key, immediate); } } } diff --git a/spring-context/src/main/java/org/springframework/cache/interceptor/CacheEvictOperation.java b/spring-context/src/main/java/org/springframework/cache/interceptor/CacheEvictOperation.java index 137d5e25cfd1..1fa3a3cbca31 100644 --- a/spring-context/src/main/java/org/springframework/cache/interceptor/CacheEvictOperation.java +++ b/spring-context/src/main/java/org/springframework/cache/interceptor/CacheEvictOperation.java @@ -29,6 +29,8 @@ public class CacheEvictOperation extends CacheOperation { private final boolean beforeInvocation; + private final boolean immediate; + /** * Create a new {@link CacheEvictOperation} instance from the given builder. @@ -38,6 +40,7 @@ public CacheEvictOperation(CacheEvictOperation.Builder b) { super(b); this.cacheWide = b.cacheWide; this.beforeInvocation = b.beforeInvocation; + this.immediate = b.immediate; } @@ -49,6 +52,10 @@ public boolean isBeforeInvocation() { return this.beforeInvocation; } + public boolean isImmediate() { + return this.immediate; + } + /** * A builder that can be used to create a {@link CacheEvictOperation}. @@ -60,6 +67,8 @@ public static class Builder extends CacheOperation.Builder { private boolean beforeInvocation = false; + private boolean immediate = false; + public void setCacheWide(boolean cacheWide) { this.cacheWide = cacheWide; } @@ -68,6 +77,10 @@ public void setBeforeInvocation(boolean beforeInvocation) { this.beforeInvocation = beforeInvocation; } + public void setImmediate(boolean immediate) { + this.immediate = immediate; + } + @Override protected StringBuilder getOperationDescription() { StringBuilder sb = super.getOperationDescription(); @@ -75,6 +88,8 @@ protected StringBuilder getOperationDescription() { sb.append(this.cacheWide); sb.append(','); sb.append(this.beforeInvocation); + sb.append(','); + sb.append(this.immediate); return sb; } diff --git a/spring-context/src/main/resources/org/springframework/cache/config/spring-cache.xsd b/spring-context/src/main/resources/org/springframework/cache/config/spring-cache.xsd index 84db378a8701..8b4428020e85 100644 --- a/spring-context/src/main/resources/org/springframework/cache/config/spring-cache.xsd +++ b/spring-context/src/main/resources/org/springframework/cache/config/spring-cache.xsd @@ -300,6 +300,14 @@ invoked (default) or before.]]> + + + + + diff --git a/spring-context/src/test/resources/org/springframework/cache/config/cache-advice.xml b/spring-context/src/test/resources/org/springframework/cache/config/cache-advice.xml index eb9ffce842ae..65faa05b5546 100644 --- a/spring-context/src/test/resources/org/springframework/cache/config/cache-advice.xml +++ b/spring-context/src/test/resources/org/springframework/cache/config/cache-advice.xml @@ -30,8 +30,10 @@ + + @@ -75,7 +77,9 @@ + + diff --git a/spring-context/src/testFixtures/java/org/springframework/context/testfixture/cache/AbstractCacheAnnotationTests.java b/spring-context/src/testFixtures/java/org/springframework/context/testfixture/cache/AbstractCacheAnnotationTests.java index 9640dd26564d..e6f4e18d92af 100644 --- a/spring-context/src/testFixtures/java/org/springframework/context/testfixture/cache/AbstractCacheAnnotationTests.java +++ b/spring-context/src/testFixtures/java/org/springframework/context/testfixture/cache/AbstractCacheAnnotationTests.java @@ -171,6 +171,20 @@ protected void testEvictEarly(CacheableService service) { assertThat(r2).isNotSameAs(r1); } + protected void testEvictImmediate(CacheableService service) { + Cache cache = this.cm.getCache("testCache"); + + Object o1 = new Object(); + cache.putIfAbsent(o1, -1L); + Object r1 = service.cache(o1); + + service.evictImmediate(o1); + assertThat(cache.get(o1)).isNull(); + + Object r2 = service.cache(o1); + assertThat(r2).isNotSameAs(r1); + } + protected void testEvictException(CacheableService service) { Object o1 = new Object(); Object r1 = service.cache(o1); @@ -270,6 +284,28 @@ protected void testEvictAllEarly(CacheableService service) { assertThat(r4).isNotSameAs(r2); } + protected void testEvictAllImmediate(CacheableService service) { + Cache cache = this.cm.getCache("testCache"); + + Object o1 = new Object(); + Object o2 = new Object(); + cache.putIfAbsent(o1, -1L); + cache.putIfAbsent(o2, -2L); + + Object r1 = service.cache(o1); + Object r2 = service.cache(o2); + assertThat(r2).isNotSameAs(r1); + + service.evictAllImmediate(new Object()); + assertThat(cache.get(o1)).isNull(); + assertThat(cache.get(o2)).isNull(); + + Object r3 = service.cache(o1); + Object r4 = service.cache(o2); + assertThat(r3).isNotSameAs(r1); + assertThat(r4).isNotSameAs(r2); + } + protected void testConditionalExpression(CacheableService service) { Object r1 = service.conditional(4); Object r2 = service.conditional(4); @@ -594,6 +630,11 @@ protected void evictEarly() { testEvictEarly(this.cs); } + @Test + protected void evictImmediate() { + testEvictImmediate(this.cs); + } + @Test protected void evictWithException() { testEvictException(this.cs); @@ -609,6 +650,11 @@ protected void evictAllEarly() { testEvictAllEarly(this.cs); } + @Test + protected void evictAllImmediate() { + testEvictAllImmediate(this.cs); + } + @Test protected void evictWithKey() { testEvictWithKey(this.cs); @@ -664,11 +710,21 @@ protected void classEvictEarly() { testEvictEarly(this.ccs); } + @Test + protected void classEvictImmediate() { + testEvictImmediate(this.ccs); + } + @Test protected void classEvictAll() { testEvictAll(this.ccs, true); } + @Test + protected void classEvictAllImmediate() { + testEvictAllImmediate(this.ccs); + } + @Test protected void classEvictWithException() { testEvictException(this.ccs); diff --git a/spring-context/src/testFixtures/java/org/springframework/context/testfixture/cache/beans/AnnotatedClassCacheableService.java b/spring-context/src/testFixtures/java/org/springframework/context/testfixture/cache/beans/AnnotatedClassCacheableService.java index aedb2b988b1c..c667b61d721c 100644 --- a/spring-context/src/testFixtures/java/org/springframework/context/testfixture/cache/beans/AnnotatedClassCacheableService.java +++ b/spring-context/src/testFixtures/java/org/springframework/context/testfixture/cache/beans/AnnotatedClassCacheableService.java @@ -92,6 +92,11 @@ public void evictEarly(Object arg1) { throw new RuntimeException("exception thrown - evict should still occur"); } + @Override + @CacheEvict(cacheNames = "testCache", key = "#p0", immediate = true) + public void evictImmediate(Object arg1) { + } + @Override @CacheEvict(cacheNames = "testCache", allEntries = true) public void evictAll(Object arg1) { @@ -103,6 +108,11 @@ public void evictAllEarly(Object arg1) { throw new RuntimeException("exception thrown - evict should still occur"); } + @Override + @CacheEvict(cacheNames = "testCache", allEntries = true, immediate = true) + public void evictAllImmediate(Object arg1) { + } + @Override @Cacheable(cacheNames = "testCache", key = "#p0") public Object key(Object arg1, Object arg2) { diff --git a/spring-context/src/testFixtures/java/org/springframework/context/testfixture/cache/beans/CacheableService.java b/spring-context/src/testFixtures/java/org/springframework/context/testfixture/cache/beans/CacheableService.java index 0ef23e63a37b..a28e6d9618be 100644 --- a/spring-context/src/testFixtures/java/org/springframework/context/testfixture/cache/beans/CacheableService.java +++ b/spring-context/src/testFixtures/java/org/springframework/context/testfixture/cache/beans/CacheableService.java @@ -39,10 +39,14 @@ public interface CacheableService { void evictEarly(Object arg1); + void evictImmediate(Object arg1); + void evictAll(Object arg1); void evictAllEarly(Object arg1); + void evictAllImmediate(Object arg1); + T conditional(int field); T conditionalSync(int field); diff --git a/spring-context/src/testFixtures/java/org/springframework/context/testfixture/cache/beans/DefaultCacheableService.java b/spring-context/src/testFixtures/java/org/springframework/context/testfixture/cache/beans/DefaultCacheableService.java index 456e3db50a3b..faf7c5637aa8 100644 --- a/spring-context/src/testFixtures/java/org/springframework/context/testfixture/cache/beans/DefaultCacheableService.java +++ b/spring-context/src/testFixtures/java/org/springframework/context/testfixture/cache/beans/DefaultCacheableService.java @@ -79,6 +79,11 @@ public void evictEarly(Object arg1) { throw new RuntimeException("exception thrown - evict should still occur"); } + @Override + @CacheEvict(cacheNames = "testCache", key = "#p0", immediate = true) + public void evictImmediate(Object arg1) { + } + @Override @CacheEvict(cacheNames = "testCache", allEntries = true) public void evictAll(Object arg1) { @@ -90,6 +95,11 @@ public void evictAllEarly(Object arg1) { throw new RuntimeException("exception thrown - evict should still occur"); } + @Override + @CacheEvict(cacheNames = "testCache", allEntries = true, immediate = true) + public void evictAllImmediate(Object arg1) { + } + @Override @Cacheable(cacheNames = "testCache", condition = "#p0 == 3") public Long conditional(int classField) {