package org.molgenis.data.cache.l2; import com.github.benmanes.caffeine.cache.Caffeine; import com.github.benmanes.caffeine.guava.CaffeinatedGuava; import com.google.common.cache.CacheLoader; import com.google.common.cache.LoadingCache; import org.molgenis.data.Entity; import org.molgenis.data.EntityKey; import org.molgenis.data.MolgenisDataException; import org.molgenis.data.Repository; import org.molgenis.data.cache.utils.EntityHydration; import org.molgenis.data.meta.model.EntityType; import org.molgenis.data.transaction.DefaultMolgenisTransactionListener; import org.molgenis.data.transaction.MolgenisTransactionManager; import org.molgenis.data.transaction.TransactionInformation; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import javax.annotation.Nonnull; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; import java.util.stream.Stream; import static com.google.common.collect.Maps.newConcurrentMap; import static java.util.Objects.requireNonNull; import static java.util.Optional.empty; import static java.util.concurrent.TimeUnit.MINUTES; import static java.util.stream.Collectors.toMap; import static java.util.stream.StreamSupport.stream; /** * In-memory cache of entities read from cacheable repositories. */ @Service public class L2Cache extends DefaultMolgenisTransactionListener { private static final Logger LOG = LoggerFactory.getLogger(L2Cache.class); private static final int MAX_CACHE_SIZE_PER_ENTITY = 1000; /** * maps entity name to the loading cache with Object key and Optional dehydrated entity value */ private final ConcurrentMap<String, LoadingCache<Object, Optional<Map<String, Object>>>> caches; private final EntityHydration entityHydration; private final TransactionInformation transactionInformation; @Autowired public L2Cache(MolgenisTransactionManager molgenisTransactionManager, EntityHydration entityHydration, TransactionInformation transactionInformation) { this.entityHydration = requireNonNull(entityHydration); this.transactionInformation = requireNonNull(transactionInformation); caches = newConcurrentMap(); requireNonNull(molgenisTransactionManager).addTransactionListener(this); } @Override public void afterCommitTransaction(String transactionId) { //TODO: trace logging transactionInformation.getEntirelyDirtyRepositories().forEach(caches::remove); transactionInformation.getDirtyEntities().forEach(this::evict); } private void evict(EntityKey entityKey) { LoadingCache<Object, Optional<Map<String, Object>>> cache = caches.get(entityKey.getEntityName()); if (cache != null) { cache.invalidate(entityKey.getId()); } } /** * Retrieves an entity from the cache or the underlying repository. * * @param repository the underlying repository * @param id the ID of the entity to retrieve * @return the retrieved Entity, or null if the entity is not present. * @throws com.google.common.util.concurrent.UncheckedExecutionException if the repository throws an error when * loading the entity */ public Entity get(Repository<Entity> repository, Object id) { LoadingCache<Object, Optional<Map<String, Object>>> cache = getEntityCache(repository); EntityType entityType = repository.getEntityType(); return cache.getUnchecked(id).map(e -> entityHydration.hydrate(e, entityType)).orElse(null); } /** * Retrieves a list of entities from the cache. If the cache doesn't yet exist, will create the cache. * * @param repository the underlying repository, used to create the cache loader or to retrieve the existing cache * @param ids {@link Iterable} of the ids of the entities to retrieve * @return List containing the retrieved entities, missing values are excluded * @throws RuntimeException if the cache failed to load the entities */ public List<Entity> getBatch(Repository<Entity> repository, Iterable<Object> ids) { try { return getEntityCache(repository).getAll(ids).values().stream().filter(Optional::isPresent) .map(Optional::get).map(e -> entityHydration.hydrate(e, repository.getEntityType())) .collect(Collectors.toList()); } catch (ExecutionException exception) { // rethrow unchecked if (exception.getCause() != null && exception.getCause() instanceof RuntimeException) { throw (RuntimeException) exception.getCause(); } throw new MolgenisDataException(exception); } } /** * Logs cumulative cache statistics for all known caches. */ @Scheduled(fixedRate = 60000) public void logStatistics() { //TODO: do we want to log diff with last log instead? if (LOG.isDebugEnabled()) { LOG.debug("Cache stats:"); for (Map.Entry<String, LoadingCache<Object, Optional<Map<String, Object>>>> cacheEntry : caches.entrySet()) { LOG.debug("{}:{}", cacheEntry.getKey(), cacheEntry.getValue().stats()); } } } /** * Gets the existing entity cache for a {@link Repository} or creates a new one if no cache exists yet. * * @param repository the Repository used to create a new cache if none found, otherwise only the name of the * repository is used to look up the existing cache * @return the LoadingCache for the repository */ private LoadingCache<Object, Optional<Map<String, Object>>> getEntityCache(Repository<Entity> repository) { String name = repository.getName(); if (!caches.containsKey(name)) { caches.putIfAbsent(name, createEntityCache(repository)); } return caches.get(name); } /** * Creates a new Entity cache * * @param repository the {@link Repository} to load the entities from * @return newly created LoadingCache */ private LoadingCache<Object, Optional<Map<String, Object>>> createEntityCache(Repository<Entity> repository) { return CaffeinatedGuava.build(Caffeine.newBuilder().recordStats().maximumSize(MAX_CACHE_SIZE_PER_ENTITY) .expireAfterAccess(10, MINUTES), createCacheLoader(repository)); } /** * Creates a CacheLoader that loads entities from the repository and dehydrates them. * * @param repository the Repository to load the entities from * @return the {@link CacheLoader} */ private CacheLoader<Object, Optional<Map<String, Object>>> createCacheLoader(final Repository<Entity> repository) { return new CacheLoader<Object, Optional<Map<String, Object>>>() { /** * Loads a single entity from the repository. * @param id ID value of the entity to retrieve * @return dehydrated entity or empty if the entity was not present in the repository */ @Override public Optional<Map<String, Object>> load(@Nonnull Object id) { return Optional.ofNullable(repository.findOneById(id)).map(entityHydration::dehydrate); } /** * Loads multiple entities from the repository. * @param ids Iterable of String representations of the ID values * @return Map mapping id to loaded entity, or to empty optional if the entity was not present in the repository */ @Override public Map<Object, Optional<Map<String, Object>>> loadAll(Iterable<?> ids) { Stream<Object> typedIds = stream(ids.spliterator(), false).map(id -> id); Map<Object, Optional<Map<String, Object>>> result = repository.findAll(typedIds) .collect(toMap(Entity::getIdValue, this::dehydrateEntity)); for (Object key : ids) { // cache the absence of these entities in the backend as empty values result.putIfAbsent(key, empty()); } return result; } private Optional<Map<String, Object>> dehydrateEntity(Entity entity) { return Optional.of(entityHydration.dehydrate(entity)); } }; } }