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); + } + } + }