Search code examples
javaspringcaching

Spring @CacheEvict using wildcards


Is there any way of using wildcards in @CacheEvict?

I have an application with multi-tenancy that sometimes needs to evict all the data from the cache of the tenant, but not of all tenants in the system.

Consider the following method:

@Cacheable(value="users", key="T(Security).getTenant() + #user.key")
public List<User> getUsers(User user) {
    ...
}

So, I would like to do something like:

@CacheEvict(value="users", key="T(Security).getTenant() + *")
public void deleteOrganization(Organization organization) {
    ...
}

Is there anyway to do it?


Solution

  • As with 99% of every question in the universe, the answer is: it depends. If your cache manager implements something that deals with that, great. But that doesn't seem to be the case.

    If you're using SimpleCacheManager, which is a basic in-memory cache manager provided by Spring, you're probably using ConcurrentMapCache that also comes with Spring. Although it's not possible to extend ConcurrentMapCache to deal with wildcards in keys (because the cache store is private and you can't access it), you could just use it as an inspiration for your own implementation.

    Below there's a possible implementation (I didn't really test it much other than to check if it's working). This is a plain copy of ConcurrentMapCache with a modification on the evict() method. The difference is that this version of evict() treats the key to see if it's a regex. In that case, it iterates through all the keys in the store and evict the ones that match the regex.

    package com.sigraweb.cache;
    
    import java.io.Serializable;
    import java.util.concurrent.ConcurrentHashMap;
    import java.util.concurrent.ConcurrentMap;
    
    import org.springframework.cache.Cache;
    import org.springframework.cache.support.SimpleValueWrapper;
    import org.springframework.util.Assert;
    
    public class RegexKeyCache implements Cache {
        private static final Object NULL_HOLDER = new NullHolder();
    
        private final String name;
    
        private final ConcurrentMap<Object, Object> store;
    
        private final boolean allowNullValues;
    
        public RegexKeyCache(String name) {
            this(name, new ConcurrentHashMap<Object, Object>(256), true);
        }
    
        public RegexKeyCache(String name, boolean allowNullValues) {
            this(name, new ConcurrentHashMap<Object, Object>(256), allowNullValues);
        }
    
        public RegexKeyCache(String name, ConcurrentMap<Object, Object> store, boolean allowNullValues) {
            Assert.notNull(name, "Name must not be null");
            Assert.notNull(store, "Store must not be null");
            this.name = name;
            this.store = store;
            this.allowNullValues = allowNullValues;
        }
    
        @Override
        public final String getName() {
            return this.name;
        }
    
        @Override
        public final ConcurrentMap<Object, Object> getNativeCache() {
            return this.store;
        }
    
        public final boolean isAllowNullValues() {
            return this.allowNullValues;
        }
    
        @Override
        public ValueWrapper get(Object key) {
            Object value = this.store.get(key);
            return toWrapper(value);
        }
    
        @Override
        @SuppressWarnings("unchecked")
        public <T> T get(Object key, Class<T> type) {
            Object value = fromStoreValue(this.store.get(key));
            if (value != null && type != null && !type.isInstance(value)) {
                throw new IllegalStateException("Cached value is not of required type [" + type.getName() + "]: " + value);
            }
            return (T) value;
        }
    
        @Override
        public void put(Object key, Object value) {
            this.store.put(key, toStoreValue(value));
        }
    
        @Override
        public ValueWrapper putIfAbsent(Object key, Object value) {
            Object existing = this.store.putIfAbsent(key, value);
            return toWrapper(existing);
        }
    
        @Override
        public void evict(Object key) {
            this.store.remove(key);
            if (key.toString().startsWith("regex:")) {
                String r = key.toString().replace("regex:", "");
                for (Object k : this.store.keySet()) {
                    if (k.toString().matches(r)) {
                        this.store.remove(k);
                    }
                }
            }
        }
    
        @Override
        public void clear() {
            this.store.clear();
        }
    
        protected Object fromStoreValue(Object storeValue) {
            if (this.allowNullValues && storeValue == NULL_HOLDER) {
                return null;
            }
            return storeValue;
        }
    
        protected Object toStoreValue(Object userValue) {
            if (this.allowNullValues && userValue == null) {
                return NULL_HOLDER;
            }
            return userValue;
        }
    
        private ValueWrapper toWrapper(Object value) {
            return (value != null ? new SimpleValueWrapper(fromStoreValue(value)) : null);
        }
    
        @SuppressWarnings("serial")
        private static class NullHolder implements Serializable {
        }
    }
    

    I trust that readers know how to initialize the cache manager with a custom cache implementation. There's lots of documentation out there that shows you how to do that. After your project is properly configured, you can use the annotation normally like so:

    @CacheEvict(value = { "cacheName" }, key = "'regex:#tenant'+'.*'")
    public myMethod(String tenant){
    ...
    }
    

    Again, this is far from being properly tested, but it gives you a way to do what you want. If you're using another cache manager, you could extends its cache implementation similarly.