From 25220dfd62538a98d7a8c32a1aad1238fe178af2 Mon Sep 17 00:00:00 2001 From: seonghun lee Date: Thu, 17 Sep 2026 23:08:29 +0900 Subject: [PATCH] Support CacheErrorHandler in TransactionAwareCacheDecorator A CacheErrorHandler configured via CachingConfigurer only applies within intercepted cache invocations. With transaction-aware caching, put, evict and clear operations are deferred to the after-commit phase and run outside the intercepted invocation, so failures of the deferred operations bypass the configured error handler and propagate to the caller of the transaction commit. In particular, this prevents suppressing cache backend outages via LoggingCacheErrorHandler. TransactionAwareCacheDecorator now optionally accepts a CacheErrorHandler to invoke for put, evict and clear failures in the after-commit phase. AbstractTransactionSupportingCacheManager exposes a corresponding errorHandler property, applied when decorating caches for transaction awareness. Without an error handler, failures are propagated as before. Closes gh-28554 Signed-off-by: seonghun lee --- ...ractTransactionSupportingCacheManager.java | 33 +++++- .../TransactionAwareCacheDecorator.java | 55 +++++++++- .../TransactionAwareCacheDecoratorTests.java | 103 ++++++++++++++++++ 3 files changed, 187 insertions(+), 4 deletions(-) diff --git a/spring-context-support/src/main/java/org/springframework/cache/transaction/AbstractTransactionSupportingCacheManager.java b/spring-context-support/src/main/java/org/springframework/cache/transaction/AbstractTransactionSupportingCacheManager.java index b2cc1c6db0db..df05bf0ec079 100644 --- a/spring-context-support/src/main/java/org/springframework/cache/transaction/AbstractTransactionSupportingCacheManager.java +++ b/spring-context-support/src/main/java/org/springframework/cache/transaction/AbstractTransactionSupportingCacheManager.java @@ -16,7 +16,10 @@ package org.springframework.cache.transaction; +import org.jspecify.annotations.Nullable; + import org.springframework.cache.Cache; +import org.springframework.cache.interceptor.CacheErrorHandler; import org.springframework.cache.support.AbstractCacheManager; /** @@ -25,6 +28,7 @@ * on explicitly through the {@link #setTransactionAware} bean property. * * @author Juergen Hoeller + * @author Seonghun Lee * @since 3.2 * @see #setTransactionAware * @see TransactionAwareCacheDecorator @@ -34,6 +38,8 @@ public abstract class AbstractTransactionSupportingCacheManager extends Abstract private boolean transactionAware = false; + private @Nullable CacheErrorHandler errorHandler; + /** * Set whether this CacheManager should expose transaction-aware Cache objects. @@ -52,10 +58,35 @@ public boolean isTransactionAware() { return this.transactionAware; } + /** + * Set the {@link CacheErrorHandler} for {@link Cache#put}, {@link Cache#evict} + * and {@link Cache#clear} failures in the after-commit phase of a transaction, + * applied when this CacheManager is {@linkplain #setTransactionAware + * transaction-aware}. + *

By default, such failures are propagated to the caller of the transaction + * commit, bypassing any error handler configured at the cache interception + * level, since the deferred operation runs outside the intercepted cache + * invocation. + * @since 7.1 + * @see TransactionAwareCacheDecorator + */ + public void setErrorHandler(@Nullable CacheErrorHandler errorHandler) { + this.errorHandler = errorHandler; + } + + /** + * Return the {@link CacheErrorHandler} for after-commit cache operation + * failures, if any. + * @since 7.1 + */ + public @Nullable CacheErrorHandler getErrorHandler() { + return this.errorHandler; + } + @Override protected Cache decorateCache(Cache cache) { - return (isTransactionAware() ? new TransactionAwareCacheDecorator(cache) : cache); + return (isTransactionAware() ? new TransactionAwareCacheDecorator(cache, getErrorHandler()) : cache); } } diff --git a/spring-context-support/src/main/java/org/springframework/cache/transaction/TransactionAwareCacheDecorator.java b/spring-context-support/src/main/java/org/springframework/cache/transaction/TransactionAwareCacheDecorator.java index b3de98cbb457..3ba97cb7216f 100644 --- a/spring-context-support/src/main/java/org/springframework/cache/transaction/TransactionAwareCacheDecorator.java +++ b/spring-context-support/src/main/java/org/springframework/cache/transaction/TransactionAwareCacheDecorator.java @@ -23,6 +23,7 @@ import org.jspecify.annotations.Nullable; import org.springframework.cache.Cache; +import org.springframework.cache.interceptor.CacheErrorHandler; import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.util.Assert; @@ -42,6 +43,7 @@ * @author Juergen Hoeller * @author Stephane Nicoll * @author Stas Volsky + * @author Seonghun Lee * @since 3.2 * @see TransactionAwareCacheManagerProxy */ @@ -49,14 +51,34 @@ public class TransactionAwareCacheDecorator implements Cache { private final Cache targetCache; + private final @Nullable CacheErrorHandler errorHandler; + /** * Create a new TransactionAwareCache for the given target Cache. * @param targetCache the target Cache to decorate */ public TransactionAwareCacheDecorator(Cache targetCache) { + this(targetCache, null); + } + + /** + * Create a new TransactionAwareCache for the given target Cache, using the + * given {@link CacheErrorHandler} for {@link #put}, {@link #evict} and + * {@link #clear} failures in the after-commit phase of a transaction. + *

Without an error handler, such failures are propagated to the caller + * of the transaction commit, bypassing any error handler configured at the + * cache interception level, since the deferred operation runs outside the + * intercepted cache invocation. + * @param targetCache the target Cache to decorate + * @param errorHandler the error handler to invoke for cache operation + * failures in the after-commit phase (may be {@code null} to propagate them) + * @since 7.1 + */ + public TransactionAwareCacheDecorator(Cache targetCache, @Nullable CacheErrorHandler errorHandler) { Assert.notNull(targetCache, "Target Cache must not be null"); this.targetCache = targetCache; + this.errorHandler = errorHandler; } @@ -108,7 +130,16 @@ public void put(final Object key, final @Nullable Object value) { TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { - TransactionAwareCacheDecorator.this.targetCache.put(key, value); + try { + TransactionAwareCacheDecorator.this.targetCache.put(key, value); + } + catch (RuntimeException ex) { + if (TransactionAwareCacheDecorator.this.errorHandler == null) { + throw ex; + } + TransactionAwareCacheDecorator.this.errorHandler.handleCachePutError( + ex, TransactionAwareCacheDecorator.this.targetCache, key, value); + } } }); } @@ -128,7 +159,16 @@ public void evict(final Object key) { TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { - TransactionAwareCacheDecorator.this.targetCache.evict(key); + try { + TransactionAwareCacheDecorator.this.targetCache.evict(key); + } + catch (RuntimeException ex) { + if (TransactionAwareCacheDecorator.this.errorHandler == null) { + throw ex; + } + TransactionAwareCacheDecorator.this.errorHandler.handleCacheEvictError( + ex, TransactionAwareCacheDecorator.this.targetCache, key); + } } }); } @@ -148,7 +188,16 @@ public void clear() { TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { - targetCache.clear(); + try { + TransactionAwareCacheDecorator.this.targetCache.clear(); + } + catch (RuntimeException ex) { + if (TransactionAwareCacheDecorator.this.errorHandler == null) { + throw ex; + } + TransactionAwareCacheDecorator.this.errorHandler.handleCacheClearError( + ex, TransactionAwareCacheDecorator.this.targetCache); + } } }); } diff --git a/spring-context-support/src/test/java/org/springframework/cache/transaction/TransactionAwareCacheDecoratorTests.java b/spring-context-support/src/test/java/org/springframework/cache/transaction/TransactionAwareCacheDecoratorTests.java index 3fa4f0a83b70..979e029cfe1f 100644 --- a/spring-context-support/src/test/java/org/springframework/cache/transaction/TransactionAwareCacheDecoratorTests.java +++ b/spring-context-support/src/test/java/org/springframework/cache/transaction/TransactionAwareCacheDecoratorTests.java @@ -16,19 +16,26 @@ package org.springframework.cache.transaction; +import java.util.ArrayList; +import java.util.List; + +import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; import org.springframework.cache.Cache; import org.springframework.cache.concurrent.ConcurrentMapCache; +import org.springframework.cache.interceptor.CacheErrorHandler; import org.springframework.transaction.support.TransactionTemplate; import org.springframework.transaction.testfixture.CallCountingTransactionManager; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; /** * @author Stephane Nicoll * @author Juergen Hoeller + * @author Seonghun Lee */ class TransactionAwareCacheDecoratorTests { @@ -221,4 +228,100 @@ void invalidateTransactional() { // no transactional support for invalidate assertThat(target.get(key)).isNull(); } + @Test // gh-28554 + void putTransactionalWithFailureAndErrorHandler() { + Cache target = new FailingCache("testCache"); + CollectingCacheErrorHandler errorHandler = new CollectingCacheErrorHandler(); + Cache cache = new TransactionAwareCacheDecorator(target, errorHandler); + Object key = new Object(); + + txTemplate.executeWithoutResult(s -> cache.put(key, "123")); + + assertThat(errorHandler.handled).singleElement().isInstanceOf(SimulatedFailure.class); + } + + @Test // gh-28554 + void evictTransactionalWithFailureAndErrorHandler() { + Cache target = new FailingCache("testCache"); + CollectingCacheErrorHandler errorHandler = new CollectingCacheErrorHandler(); + Cache cache = new TransactionAwareCacheDecorator(target, errorHandler); + + txTemplate.executeWithoutResult(s -> cache.evict(new Object())); + + assertThat(errorHandler.handled).singleElement().isInstanceOf(SimulatedFailure.class); + } + + @Test // gh-28554 + void clearTransactionalWithFailureAndErrorHandler() { + Cache target = new FailingCache("testCache"); + CollectingCacheErrorHandler errorHandler = new CollectingCacheErrorHandler(); + Cache cache = new TransactionAwareCacheDecorator(target, errorHandler); + + txTemplate.executeWithoutResult(s -> cache.clear()); + + assertThat(errorHandler.handled).singleElement().isInstanceOf(SimulatedFailure.class); + } + + @Test // gh-28554 + void putTransactionalWithFailureAndNoErrorHandler() { + Cache target = new FailingCache("testCache"); + Cache cache = new TransactionAwareCacheDecorator(target); + Object key = new Object(); + + assertThatExceptionOfType(SimulatedFailure.class) + .isThrownBy(() -> txTemplate.executeWithoutResult(s -> cache.put(key, "123"))); + } + + + @SuppressWarnings("serial") + private static class SimulatedFailure extends RuntimeException { + } + + private static class FailingCache extends ConcurrentMapCache { + + FailingCache(String name) { + super(name); + } + + @Override + public void put(Object key, @Nullable Object value) { + throw new SimulatedFailure(); + } + + @Override + public void evict(Object key) { + throw new SimulatedFailure(); + } + + @Override + public void clear() { + throw new SimulatedFailure(); + } + } + + private static class CollectingCacheErrorHandler implements CacheErrorHandler { + + final List handled = new ArrayList<>(); + + @Override + public void handleCacheGetError(RuntimeException exception, Cache cache, Object key) { + this.handled.add(exception); + } + + @Override + public void handleCachePutError(RuntimeException exception, Cache cache, Object key, @Nullable Object value) { + this.handled.add(exception); + } + + @Override + public void handleCacheEvictError(RuntimeException exception, Cache cache, Object key) { + this.handled.add(exception); + } + + @Override + public void handleCacheClearError(RuntimeException exception, Cache cache) { + this.handled.add(exception); + } + } + }