Search code examples
javaspring-bootcaching

Spring Cache @Cacheable - seem to have transitive effect through the methods


I have the following method in one of my services. Since the operation in getHierarchy() method is expensive, I chose to cache it:

@Service
@RequiredArgsConstructor
@Log4j2
public class ProductHierarchyServiceImpl implements ProductHierarchyService {
  private final ProductGroupRepository repository;
  private final ProductTreeMapper mapper;

  @Override
  @Cacheable(value = HIERARCHY)
  @Trace(dispatcher = true)
  public List<ProductHierarchyBean> getHierarchy() {
    var productGroups = repository.findAll();
    return mapper.productGroupsToProductHierarchyBeans(productGroups);
  }

That works well and I confirm the List<ProductHierarchyBean> gets cached between cache purges so the method is not invoked, as expected.

I then invoke that method from another service:

@Service
@RequiredArgsConstructor
@Log4j2
public class ProductGroupAccessServiceImpl implements ProductGroupAccessService {
  private final LicenceCategoryAccessRepository accessRepository;
  private final ProductHierarchyService productHierarchyService;

  @Override
  public List<ProductHierarchyBean> getFullHierarchy(Integer licenceId) {
    List<ProductHierarchyBean> hierarchy = productHierarchyService.getHierarchy();
    
        ... // more transformations here to add licensing to the hierarchy nodes
    return hierarchy;
   }

  @Override
  public List<ProductHierarchyBean> getLicensedHierarchy(Integer licenceId) {
    List<ProductHierarchyBean> licensedHierarchy = getFullHierarchy(licenceId);

    trimHierarchyToLicencedNodes(licensedHierarchy); ... // perform transformations here to remove unlicensed nodes
    return licensedHierarchy;
  }

The idea is the ProductHierarchyService provides full hierarchy of all available products, whereas the ProductGroupAccessService applies licence-based filtering that removes non-licensed nodes.

Both services methods of ProductGroupAccessService are invoked from different end points, getFullHierarchy() to retrieve full hierarchy used for editing hierarchy by admin users, and getLicensedHierarchy() by the actual user who builds a report based on the licensed hierarchy (and only have access to the allowed nodes).

So far so good, the problem starts when these REST end points (or the above methods directly) are invoked in turn. If getFullHierarchy invoked after getLicensedHierarchy, the truncated hierarchy with unlicensed nodes removed is served as if it were the full hierarchy from the cache.

If I remove the @Cache from ProductHierarchyService.getHierarchy() it all works as expected, and a hierarchy gets refreshed every time, albeit at a speed cost.

I figured the downstream service uses the same object as initially was retrieved and stored in the cache, and it modifies that.

To overcome that, I've implemented a cloneHierarchy() method, that creates a fresh copy of the hiserarchy, which I use as below:

  @Override
  @Cacheable(value = ARGOSHIERARCHY)
  @Trace(dispatcher = true)     
  private List<ProductHierarchyBean> getHierarchy() {
    var productGroups = repository.findAll();    
    var root = mapper.productGroupsToProductHierarchyBeans(productGroups);
    return cloneHierarchy(root);
  }

But that still doesn't work since I clone inside the @Cached method itself, so whatever is returned last gets cached.

My cache configuration is bog-standard, as below:

@Configuration
@EnableCaching
@EnableScheduling
@Log4j2
public class CacheConfiguration {
  public static final String HIERARCHY = "hierarchy";

  @Bean
  public CacheManager cacheManager() {
    SimpleCacheManager cacheManager = new SimpleCacheManager();
    cacheManager.setCaches(
        List.of(
            new ConcurrentMapCache(HIERARCHY));
    return cacheManager;
  }

  @CacheEvict(
      allEntries = true,
      value = {HIERARCHY})
  @Scheduled(fixedDelayString = "${products.hierarchy.cacheMsDelay:60000}")
  public void reportCacheEvict() {
    log.debug("Flush Cache {}", LocalDateTime.now());
  }
}

Any suggestions how can I overcome this problem please? Basically, I want the hierarchy to be cached, but only one single copy, that I can clone and pass to a client on demand?


Solution

  • Figured it out - apparently I needed to clone the hierarchy in the upstream method, like this:

      @Override
      public List<ProductHierarchyBean> getFullHierarchy(Integer licenceId) {
        List<ProductHierarchyBean> hierarchy = productHierarchyService.getHierarchy();
        hierarchy = ProductHierarchyCloner.cloneHierarchy(hierarchy);
        ...
      }
    

    This way the inital full hierarchy stays cached and we clone it every time we need to use it.