LRUResultCache.java

/*******************************************************************************
 * Copyright (c) 2021 Eclipse RDF4J contributors.
 *
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the Eclipse Distribution License v1.0
 * which accompanies this distribution, and is available at
 * http://www.eclipse.org/org/documents/edl-v10.php.
 *
 * SPDX-License-Identifier: BSD-3-Clause
 *******************************************************************************/

package org.eclipse.rdf4j.spring.resultcache;

import java.lang.invoke.MethodHandles;
import java.time.Duration;
import java.time.Instant;
import java.util.Collections;
import java.util.Map;
import java.util.Objects;
import java.util.WeakHashMap;
import java.util.concurrent.atomic.AtomicBoolean;

import org.apache.commons.collections4.map.LRUMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * @param <T>
 * @author Florian Kleedorfer
 * @since 4.0.0
 */
public class LRUResultCache<T> implements ResultCache<Integer, T> {
	private static final Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
	private final Map<Integer, Entry<T>> cache;
	private final AtomicBoolean dirty = new AtomicBoolean(false);
	private final Map<Thread, Boolean> bypassInThread = Collections.synchronizedMap(new WeakHashMap<>());
	private final Duration entryLifetime;

	public LRUResultCache(ResultCacheProperties properties) {
		this.entryLifetime = properties.getEntryLifetime();
		this.cache = Collections.synchronizedMap(
				new LRUMap<>(properties.getMaxSize(), properties.getInitialSize()));
	}

	@Override
	public T get(Integer key) {
		debug("obtaining cached result for key {} from cache {}", key, hashCode());
		Objects.requireNonNull(key);
		if (dirty.get()) {
			debug("cache is dirty");
			clearCachedResults();
			debug("returning null");
			return null;
		}
		if (isBypass()) {
			debug("bypassing cache, returning null");
			return null;
		}
		Entry<T> entry = cache.get(key);
		if (entry == null) {
			debug("nothing found in cache, returning null");
			return null;
		}
		if (entry.isExpired()) {
			cache.remove(key);
			debug("cached object is expired, returning null");
			return null;
		}
		debug("returning cached object");
		return entry.getCachedObject();
	}

	private void debug(String message, Object... args) {
		if (logger.isDebugEnabled()) {
			logger.debug(message, args);
		}
	}

	private boolean isBypass() {
		return bypassInThread.containsKey(Thread.currentThread());
	}

	@Override
	public void put(Integer key, T cachedObject) {
		Objects.requireNonNull(key);
		Objects.requireNonNull(cachedObject);
		debug("about to put object {} into cache {}", key, hashCode());
		if (isBypass()) {
			debug("bypassing cache, not caching object");
			return;
		}
		if (dirty.get()) {
			debug("cache is dirty");
			clearCachedResults();
		}
		debug("putting object into cache");
		cache.put(key, new Entry<>(cachedObject));
	}

	@Override
	public void markDirty() {
		debug("marking dirty: cache {}", hashCode());
		this.dirty.set(true);
	}

	@Override
	public synchronized void clearCachedResults() {
		debug("clearing cache {}", hashCode());
		if (dirty.get()) {
			cache.clear();
			bypassInThread.clear();
			dirty.set(false);
		}
	}

	@Override
	public void bypassForCurrentThread() {
		bypassInThread.put(Thread.currentThread(), true);
	}

	private class Entry<E> {
		E cachedObject;
		Instant createdAtTimestamp = Instant.now();

		public Entry(E cachedObject) {
			this.cachedObject = cachedObject;
		}

		public E getCachedObject() {
			return cachedObject;
		}

		public boolean isExpired() {
			return createdAtTimestamp.plus(entryLifetime).isBefore(Instant.now());
		}
	}
}