Mukul Goel
Mukul Goel

Reputation: 8467

Spring boot @Cacheable not working as expected with @Transactional

I am not able to share the actual code because of corporate policies but below is an example of method structures.

So in the example I want to the cache on the method in Class B to be cleared when the exception is thrown in class A.

NB: I can not move the cache to Class A so that is not a feasible solution.

I have tried reading all answers and posts online to get this working but not able to figure it out.

Please help with suggestions. A

I have set the following properties in application.properties

spring.cache.enabled=true
spring.cache.jcache.config=classpath:cache/ehcache.xml

@EnableCaching
@EnableTransactionManagement
    Main Class{


@Autowired
CacheManager cacheManager

@PostConstruct
void postConstruct(){
(JCacheCacheManager)cachemanager).setTransactionAware(true);

}
}

@Service
Class A{

@Autowired
B b;

@Transactional
public List<Data> getAllBusinessData(){

List<Data> dataList = b.getDataFromSystem("key");

//TestCode to test cache clears if exception thrown here
throw new RuntimeException("test");

}
}

@Service
Class B{

@Cacheable("cacheName")
public List<Data> getDataFromSystem(String key){

client call code here

return dataList;

}

}

Upvotes: 4

Views: 2789

Answers (1)

jccampanero
jccampanero

Reputation: 53381

There should be other ways, but the following could be a valid solution.

The first step will be to define a custom exception in order to be able to handle it later as appropriate. This exception will receive, among others, the name of the cache and the key you want to evict. For example:

public class CauseOfEvictionException extends RuntimeException {

  public CauseOfEvictionException(String message, String cacheName, String cacheKey) {
    super(message);
    
    this.cacheName = cacheName;
    this.cacheKey = cacheKey;
  }

  // getters and setters omitted for brevity
}

This exception will be raised by your B class, in your example:

@Service
Class A{

  @Autowired
  B b;

  @Transactional
  public List<Data> getAllBusinessData(){
 
    List<Data> dataList = b.getDataFromSystem("key");

    // Sorry, because in a certain sense you need to be aware of the cache
    // name here. Probably it could be improved
    throw new CauseOfEvictionException("test", "cacheName", "key");

  }
}

Now, we need a way to handle this kind of exception.

Independently of that way, the idea is that the code responsible for handling the exception will access the configured CacheManager and trigger the cache eviction.

Because you are using Spring Boot, an easy way to deal with it is by extending ResponseEntityExceptionHandler to provide an appropriate @ExceptionHandler. Please, consider read for more information the answer I provided in this related SO question or this great article.

In summary, please, consider for example:

@ControllerAdvice
public class CustomExceptionHandler extends ResponseEntityExceptionHandler {
  
  @Autowired
  private CacheManager cacheManager;

  @ExceptionHandler(CauseOfEvictionException.class)
  public ResponseEntity<Object> handleCauseOfEvictionException(
    CauseOfEvictionException e) {
    this.cacheManager.getCache(e.getCacheName()).evict(e.getCacheKey());

    // handle the exception and provide the necessary response as you wish
    return ...;
  }
}

It is important to realize that when dealing with keys composed by several arguments by default (please, consider read this as well) the actual cache key will be wrapped as an instance of the SimpleKey class that contains all this parameters.

Please, be aware that this default behavior can be customized to a certain extend with SpEL or providing your own cache KeyGenerator. For reference, here is the current implementation of the default one provided by the framework, SimpleKeyGenerator.

Thinking about the problem, a possible solution could be the use of some kind of AOP as well. The idea will be the following.

First, define some kind of helper annotation. This annotation will be of help in determining which methods should be advised. For example:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface EvictCacheOnError {
}

The next step will be defining the aspect that will handle the actual cache eviction process. Assuming you only need to advice Spring managed beans, for simplicity we can use Spring AOP for that. You can use either an @Around or an @AfterThrowing aspect. Consider the following example:

@Aspect
@Component
public class EvictCacheOnErrorAspect {

  @Autowired
  private CacheManager cacheManager;

  @Around("@annotation(your.pkg.EvictCacheOnError)")
  public void evictCacheOnError(ProceedingJoinPoint pjp) {
    try {
      Object retVal = pjp.proceed();
      return retVal;
    } catch (CauseOfEvictionException e) {
      this.cacheManager.getCache(
          e.getCacheName()).evict(e.getCacheKey()
      );

      // rethrow 
      throw e;
    }    
  }
}

The final step would be annotate the methods in which the behavior should be applied:

@Service
Class A{

  @Autowired
  B b;

  @Transactional
  @EvictCacheOnError
  public List<Data> getAllBusinessData(){

    List<Data> dataList = b.getDataFromSystem("key");

    throw new CauseOfEvictionException("test", "cacheName", "key");
  }
}

You may even try generalizing the idea, by providing in the EvictCacheOnError annotation all the necessary information you need to perform the cache eviction:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface EvictCacheOnError {
    String cacheName();
    int[] cacheKeyArgsIndexes();
}

With the following aspect:

@Aspect
@Component
public class EvictCacheOnErrorAspect {

  @Autowired
  private CacheManager cacheManager;

  @Autowired
  private KeyGenerator keyGenerator;

  @Around("@annotation(your.pkg.EvictCacheOnError)")
  // You can inject the annotation right here if you want to
  public void evictCacheOnError(ProceedingJoinPoint pjp) {
    try {
      Object retVal = pjp.proceed();
      return retVal;
    } catch (Throwable t) {
      // Assuming only is applied on methods
      MethodSignature signature = (MethodSignature) pjp.getSignature();
      Method method = signature.getMethod();
      // Obtain a reference to the EvictCacheOnError annotation
      EvictCacheOnError evictCacheOnError = method.getAnnotation(EvictCacheOnError.class);
      // Compute cache key: some safety checks are imperative here,
      // please, excuse the simplicity of the implementation
      int[] cacheKeyArgsIndexes = evictCacheOnError.cacheKeyArgsIndexes();
      Object[] args = pjp.getArgs();
      List<Object> cacheKeyArgsList = new ArrayList<>(cacheKeyArgsIndexes.length);
      for (int i=0; i < cacheKeyArgsIndexes.length; i++) {
        cacheKeyArgsList.add(args[cacheKeyArgsIndexes[i]]);
      }
      
      Object[] cacheKeyArgs = new Object[cacheKeyArgsList.size()];
      cacheKeyArgsList.toArray(cacheKeyArgs);

      Object target = pjp.getTarget();

      Object cacheKey = this.keyGenerator.generate(target, method, cacheKeyArgs);

      // Perform actual eviction
      String cacheName = evictCacheOnError.cacheName();
      this.cacheManager.getCache(cacheName).evict(cacheKey);

      // rethrow: be careful here if using in it with transactions
      // Spring will per default only rollback unchecked exceptions
      throw new RuntimeException(t);
    }    
  }
}

This last solution depends on the actual method arguments, which may not be appropriate if the cache key is based on intermediate results obtained within your method body.

Upvotes: 4

Related Questions