add cache for docId to Doc mapping
A Doc does not change once it is created, so it is easy to cache. Speedup was from 1ms per Doc to 3ms for 444 Docs (0.00675ms/Doc).
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
|
||||
dependencies {
|
||||
|
||||
compile lib_guava
|
||||
}
|
||||
231
pdb-utils/src/main/java/org/lucares/utils/cache/HotEntryCache.java
vendored
Normal file
231
pdb-utils/src/main/java/org/lucares/utils/cache/HotEntryCache.java
vendored
Normal file
@@ -0,0 +1,231 @@
|
||||
package org.lucares.utils.cache;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.EnumSet;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
import com.google.common.util.concurrent.ThreadFactoryBuilder;
|
||||
|
||||
/**
|
||||
* A cache that only keeps 'hot' entries, that is entries that have been
|
||||
* accessed recently. Entries that have not been accessed recently are removed.
|
||||
* <p>
|
||||
* Caching frameworks like EhCache only evict entries when a new entry is added.
|
||||
* That might not be desired, e.g. when the cached objects block resources.
|
||||
* <p>
|
||||
* This cache is a simple wrapper for a ConcurrentHashMap that evicts entries
|
||||
* after timeToLive+5s.
|
||||
*/
|
||||
public class HotEntryCache<K, V> {
|
||||
|
||||
public enum EventType {
|
||||
EVICTED, REMOVED
|
||||
}
|
||||
|
||||
public interface EventListener<K, V> {
|
||||
public void onEvent(Event<K, V> event);
|
||||
}
|
||||
|
||||
public static class Event<K, V> {
|
||||
private final EventType eventType;
|
||||
private final K key;
|
||||
private final V value;
|
||||
|
||||
public Event(final EventType eventType, final K key, final V value) {
|
||||
super();
|
||||
this.eventType = eventType;
|
||||
this.key = key;
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public EventType getEventType() {
|
||||
return eventType;
|
||||
}
|
||||
|
||||
public K getKey() {
|
||||
return key;
|
||||
}
|
||||
|
||||
public V getValue() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
private final static class EventSubscribers<K, V> {
|
||||
private final EnumSet<EventType> subscribedEvents;
|
||||
private final EventListener<K, V> listener;
|
||||
|
||||
public EventSubscribers(final EnumSet<EventType> subscribedEvents, final EventListener<K, V> listener) {
|
||||
super();
|
||||
this.subscribedEvents = subscribedEvents;
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
public EnumSet<EventType> getSubscribedEvents() {
|
||||
return subscribedEvents;
|
||||
}
|
||||
|
||||
public EventListener<K, V> getListener() {
|
||||
return listener;
|
||||
}
|
||||
}
|
||||
|
||||
private final static class Entry<V> {
|
||||
private Instant lastAccessed;
|
||||
|
||||
private final V value;
|
||||
|
||||
public Entry(final V value, final Clock clock) {
|
||||
this.value = value;
|
||||
lastAccessed = Instant.now(clock);
|
||||
}
|
||||
|
||||
public Instant getLastAccessed() {
|
||||
return lastAccessed;
|
||||
}
|
||||
|
||||
public V getValue() {
|
||||
return value;
|
||||
}
|
||||
|
||||
public void touch(final Clock clock) {
|
||||
lastAccessed = Instant.now(clock);
|
||||
}
|
||||
}
|
||||
|
||||
private final ConcurrentHashMap<K, Entry<V>> cache = new ConcurrentHashMap<>();
|
||||
|
||||
private final ScheduledExecutorService evicter = Executors.newScheduledThreadPool(1,
|
||||
new ThreadFactoryBuilder().setNameFormat("eviction-%d").setDaemon(true).build());
|
||||
|
||||
private final CopyOnWriteArrayList<EventSubscribers<K, V>> listeners = new CopyOnWriteArrayList<>();
|
||||
|
||||
private final Duration timeToLive;
|
||||
|
||||
private Clock clock;
|
||||
|
||||
HotEntryCache(final Duration timeToLive, final Clock clock, final long delayForEvictionThread,
|
||||
final TimeUnit timeUnit) {
|
||||
this.timeToLive = timeToLive;
|
||||
this.clock = clock;
|
||||
evicter.scheduleWithFixedDelay(this::evict, delayForEvictionThread, delayForEvictionThread, timeUnit);
|
||||
}
|
||||
|
||||
public HotEntryCache(final Duration timeToLive) {
|
||||
this(timeToLive, Clock.systemDefaultZone(), 5, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
public void addListener(final EventListener<K, V> listener, final EventType... eventTypes) {
|
||||
listeners.add(new EventSubscribers<>(EnumSet.copyOf(Arrays.asList(eventTypes)), listener));
|
||||
}
|
||||
|
||||
public V get(final K key) {
|
||||
final Entry<V> entry = cache.computeIfPresent(key, (k, e) -> {
|
||||
if (isExpired(e)) {
|
||||
handleEvent(EventType.EVICTED, k, e.getValue());
|
||||
return null;
|
||||
}
|
||||
|
||||
e.touch(clock);
|
||||
return e;
|
||||
});
|
||||
return entry != null ? entry.getValue() : null;
|
||||
}
|
||||
|
||||
public V put(final K key, final V value) {
|
||||
|
||||
final AtomicReference<Entry<V>> oldValue = new AtomicReference<>();
|
||||
cache.compute(key, (k, v) -> {
|
||||
oldValue.set(v);
|
||||
return new Entry<>(value, clock);
|
||||
});
|
||||
return oldValue.get() != null ? oldValue.get().getValue() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the value supplied by the mappingFunction, if the key does not already
|
||||
* exist in the map. The operation is done atomically, that is the function is
|
||||
* executed at most once. This method is blocking while other threads are
|
||||
* computing the mapping function. Therefore the computation should be short and
|
||||
* simple.
|
||||
*
|
||||
* @param key key of the value
|
||||
* @param mappingFunction a function that returns the value that should be
|
||||
* inserted
|
||||
* @return the newly inserted or existing value, or null if
|
||||
* {@code mappingFunction} returned {@code null}
|
||||
*/
|
||||
public V putIfAbsent(final K key, final Function<K, V> mappingFunction) {
|
||||
|
||||
final Entry<V> entry = cache.computeIfAbsent(key, (k) -> {
|
||||
final V value = mappingFunction.apply(k);
|
||||
return new Entry<>(value, clock);
|
||||
});
|
||||
|
||||
if (entry != null) {
|
||||
entry.touch(clock);
|
||||
}
|
||||
return entry != null ? entry.getValue() : null;
|
||||
}
|
||||
|
||||
public V remove(final K key) {
|
||||
|
||||
final AtomicReference<Entry<V>> oldValue = new AtomicReference<>();
|
||||
cache.computeIfPresent(key, (k, e) -> {
|
||||
oldValue.set(e);
|
||||
handleEvent(EventType.REMOVED, k, e.getValue());
|
||||
return null;
|
||||
});
|
||||
return oldValue.get() != null ? oldValue.get().getValue() : null;
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
for (final K key : cache.keySet()) {
|
||||
remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
public void forEach(final Consumer<V> consumer) {
|
||||
cache.forEachValue(Long.MAX_VALUE, entry -> {
|
||||
entry.touch(clock);
|
||||
consumer.accept(entry.getValue());
|
||||
});
|
||||
}
|
||||
|
||||
private void evict() {
|
||||
for (final K key : cache.keySet()) {
|
||||
|
||||
cache.computeIfPresent(key, (k, e) -> {
|
||||
if (isExpired(e)) {
|
||||
handleEvent(EventType.EVICTED, k, e.getValue());
|
||||
return null;
|
||||
}
|
||||
return e;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isExpired(final Entry<V> entry) {
|
||||
return entry.getLastAccessed().plus(timeToLive).isBefore(Instant.now(clock));
|
||||
}
|
||||
|
||||
private void handleEvent(final EventType eventType, final K key, final V value) {
|
||||
|
||||
for (final EventSubscribers<K, V> eventSubscribers : listeners) {
|
||||
if (eventSubscribers.getSubscribedEvents().contains(eventType)) {
|
||||
eventSubscribers.getListener().onEvent(new Event<>(eventType, key, value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
220
pdb-utils/src/test/java/org/lucares/utils/cache/HotEntryCacheTest.java
vendored
Normal file
220
pdb-utils/src/test/java/org/lucares/utils/cache/HotEntryCacheTest.java
vendored
Normal file
@@ -0,0 +1,220 @@
|
||||
package org.lucares.utils.cache;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
import org.lucares.utils.cache.HotEntryCache.EventType;
|
||||
import org.testng.Assert;
|
||||
import org.testng.annotations.Test;
|
||||
|
||||
@Test
|
||||
public class HotEntryCacheTest {
|
||||
public void testPutAndGet() throws InterruptedException, ExecutionException, TimeoutException {
|
||||
final HotEntryCache<String, String> cache = new HotEntryCache<>(Duration.ofSeconds(10));
|
||||
|
||||
final String replacedNull = cache.put("key", "value1");
|
||||
Assert.assertEquals(replacedNull, null);
|
||||
|
||||
final String cachedValue1 = cache.get("key");
|
||||
Assert.assertEquals(cachedValue1, "value1");
|
||||
|
||||
final String replacedValue1 = cache.put("key", "value2");
|
||||
Assert.assertEquals(replacedValue1, "value1");
|
||||
|
||||
final String cachedValue2 = cache.get("key");
|
||||
Assert.assertEquals(cachedValue2, "value2");
|
||||
}
|
||||
|
||||
public void testEvictOnGet() throws InterruptedException, ExecutionException, TimeoutException {
|
||||
final ModifiableFixedTimeClock clock = new ModifiableFixedTimeClock();
|
||||
final Duration timeToLive = Duration.ofSeconds(10);
|
||||
final HotEntryCache<String, String> cache = new HotEntryCache<>(timeToLive, clock, 1, TimeUnit.MILLISECONDS);
|
||||
|
||||
cache.put("key", "value1");
|
||||
|
||||
clock.plus(timeToLive.plusMillis(1));
|
||||
|
||||
final String cachedValue1_evicted = cache.get("key");
|
||||
Assert.assertEquals(cachedValue1_evicted, null);
|
||||
}
|
||||
|
||||
public void testEvictionByBackgroundThread() throws InterruptedException, ExecutionException, TimeoutException {
|
||||
final ModifiableFixedTimeClock clock = new ModifiableFixedTimeClock();
|
||||
final Duration timeToLive = Duration.ofSeconds(10);
|
||||
final HotEntryCache<String, String> cache = new HotEntryCache<>(timeToLive, clock, 1, TimeUnit.MILLISECONDS);
|
||||
|
||||
final CompletableFuture<String> evictionEventFuture = new CompletableFuture<>();
|
||||
cache.addListener(event -> {
|
||||
evictionEventFuture.complete(event.getValue());
|
||||
}, EventType.EVICTED);
|
||||
|
||||
cache.put("key", "value1");
|
||||
|
||||
clock.plus(timeToLive.plusMillis(1));
|
||||
|
||||
final String evictedValue1 = evictionEventFuture.get(5, TimeUnit.MINUTES); // enough time for debugging
|
||||
Assert.assertEquals(evictedValue1, "value1");
|
||||
}
|
||||
|
||||
public void testRemove() throws InterruptedException, ExecutionException, TimeoutException {
|
||||
final HotEntryCache<String, String> cache = new HotEntryCache<>(Duration.ofSeconds(10));
|
||||
|
||||
final List<String> removedValues = new ArrayList<>();
|
||||
cache.addListener(event -> removedValues.add(event.getValue()), EventType.REMOVED);
|
||||
|
||||
cache.put("key", "value1");
|
||||
|
||||
final String removedValue = cache.remove("key");
|
||||
Assert.assertEquals(removedValue, "value1");
|
||||
|
||||
Assert.assertEquals(removedValues, Arrays.asList("value1"));
|
||||
|
||||
Assert.assertEquals(cache.get("key"), null);
|
||||
}
|
||||
|
||||
public void testClear() throws InterruptedException, ExecutionException, TimeoutException {
|
||||
final HotEntryCache<String, String> cache = new HotEntryCache<>(Duration.ofSeconds(10));
|
||||
|
||||
final List<String> removedValues = new ArrayList<>();
|
||||
cache.addListener(event -> removedValues.add(event.getValue()), EventType.REMOVED);
|
||||
|
||||
cache.put("key1", "value1");
|
||||
cache.put("key2", "value2");
|
||||
|
||||
cache.clear();
|
||||
|
||||
Assert.assertEquals(cache.get("key1"), null);
|
||||
Assert.assertEquals(cache.get("key2"), null);
|
||||
|
||||
Assert.assertEquals(removedValues, Arrays.asList("value1", "value2"));
|
||||
}
|
||||
|
||||
public void testForEachTouches() throws InterruptedException, ExecutionException, TimeoutException {
|
||||
final ModifiableFixedTimeClock clock = new ModifiableFixedTimeClock();
|
||||
final Duration timeToLive = Duration.ofSeconds(10);
|
||||
final HotEntryCache<String, String> cache = new HotEntryCache<>(timeToLive, clock, 1, TimeUnit.HOURS);
|
||||
|
||||
final CompletableFuture<String> evictionEventFuture = new CompletableFuture<>();
|
||||
cache.addListener(event -> {
|
||||
evictionEventFuture.complete(event.getValue());
|
||||
}, EventType.EVICTED);
|
||||
|
||||
// add value
|
||||
cache.put("key", "value1");
|
||||
|
||||
// seek, so that it is almost evicted
|
||||
clock.plus(timeToLive.minusMillis(1));
|
||||
|
||||
// the for each should touch the entries
|
||||
cache.forEach(s -> {
|
||||
/* no-op */});
|
||||
|
||||
// seek again
|
||||
clock.plus(timeToLive.minusMillis(1));
|
||||
|
||||
// if the touch didn't happen, then the value is now evicted
|
||||
Assert.assertEquals(evictionEventFuture.isDone(), false);
|
||||
|
||||
// seek again, so that the entry will get evicted
|
||||
clock.plus(timeToLive.minusMillis(1));
|
||||
|
||||
Assert.assertEquals(cache.get("key"), null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks that
|
||||
* {@link HotEntryCache#putIfAbsent(Object, java.util.function.Function)
|
||||
* putIfAbsent} is atomic by calling
|
||||
* {@link HotEntryCache#putIfAbsent(Object, java.util.function.Function)
|
||||
* putIfAbsent} in two threads and asserting that the supplier was only called
|
||||
* once.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public void testPutIfAbsentIsAtomic() throws Exception {
|
||||
final HotEntryCache<String, String> cache = new HotEntryCache<>(Duration.ofSeconds(10));
|
||||
|
||||
final ExecutorService pool = Executors.newCachedThreadPool();
|
||||
try {
|
||||
final CountDownLatch latch = new CountDownLatch(1);
|
||||
|
||||
final String key = "key";
|
||||
final String valueA = "A";
|
||||
final String valueB = "B";
|
||||
|
||||
pool.submit(() -> {
|
||||
cache.putIfAbsent(key, k -> {
|
||||
latch.countDown();
|
||||
sleep(TimeUnit.MILLISECONDS, 20);
|
||||
return valueA;
|
||||
});
|
||||
return null;
|
||||
});
|
||||
pool.submit(() -> {
|
||||
waitFor(latch);
|
||||
cache.putIfAbsent(key, k -> valueB);
|
||||
return null;
|
||||
});
|
||||
|
||||
pool.shutdown();
|
||||
pool.awaitTermination(1, TimeUnit.MINUTES);
|
||||
|
||||
final String actual = cache.get(key);
|
||||
Assert.assertEquals(actual, valueA);
|
||||
} finally {
|
||||
pool.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
public void testPutIfAbsentReturnsExistingValue() throws Exception {
|
||||
final HotEntryCache<String, String> cache = new HotEntryCache<>(Duration.ofSeconds(10));
|
||||
|
||||
final String key = "key";
|
||||
final String valueA = "A";
|
||||
final String valueB = "B";
|
||||
|
||||
cache.put(key, valueA);
|
||||
|
||||
final String returnedByPutIfAbsent = cache.putIfAbsent(key, k -> valueB);
|
||||
Assert.assertEquals(returnedByPutIfAbsent, valueA);
|
||||
|
||||
final String actualInCache = cache.get(key);
|
||||
Assert.assertEquals(actualInCache, valueA);
|
||||
}
|
||||
|
||||
public void testPutIfAbsentDoesNotAddNull() throws Exception {
|
||||
final HotEntryCache<String, String> cache = new HotEntryCache<>(Duration.ofSeconds(10));
|
||||
|
||||
final String key = "key";
|
||||
final String returnedByPutIfAbsent = cache.putIfAbsent(key, k -> null);
|
||||
Assert.assertNull(returnedByPutIfAbsent, null);
|
||||
|
||||
final String actualInCache = cache.get(key);
|
||||
Assert.assertEquals(actualInCache, null);
|
||||
}
|
||||
|
||||
private void sleep(final TimeUnit timeUnit, final long timeout) {
|
||||
try {
|
||||
timeUnit.sleep(timeout);
|
||||
} catch (final InterruptedException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void waitFor(final CountDownLatch latch) {
|
||||
try {
|
||||
latch.await(1, TimeUnit.MINUTES);
|
||||
} catch (final InterruptedException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
98
pdb-utils/src/test/java/org/lucares/utils/cache/ModifiableFixedTimeClock.java
vendored
Normal file
98
pdb-utils/src/test/java/org/lucares/utils/cache/ModifiableFixedTimeClock.java
vendored
Normal file
@@ -0,0 +1,98 @@
|
||||
package org.lucares.utils.cache;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.temporal.TemporalAmount;
|
||||
import java.time.temporal.TemporalUnit;
|
||||
|
||||
/**
|
||||
* A {@link Clock} with a fixed, but modifiable, time. This {@link Clock} is
|
||||
* useful in tests, so that you can explicitly set the time.
|
||||
*/
|
||||
public class ModifiableFixedTimeClock extends Clock implements Serializable {
|
||||
private static final long serialVersionUID = 1955332545617873736L;
|
||||
private Instant instant;
|
||||
private final ZoneId zone;
|
||||
|
||||
public ModifiableFixedTimeClock() {
|
||||
this(Instant.now());
|
||||
}
|
||||
|
||||
public ModifiableFixedTimeClock(final Instant fixedInstant) {
|
||||
this(fixedInstant, ZoneId.systemDefault());
|
||||
}
|
||||
|
||||
public ModifiableFixedTimeClock(final Instant fixedInstant, final ZoneId zone) {
|
||||
this.instant = fixedInstant;
|
||||
this.zone = zone;
|
||||
}
|
||||
|
||||
public void setTime(final Instant instant) {
|
||||
this.instant = instant;
|
||||
}
|
||||
|
||||
public void plus(final TemporalAmount amountToAdd) {
|
||||
instant = instant.plus(amountToAdd);
|
||||
}
|
||||
|
||||
public void plus(final long amountToAdd, final TemporalUnit unit) {
|
||||
instant = instant.plus(amountToAdd, unit);
|
||||
}
|
||||
|
||||
public void plusMillis(final long millisToAdd) {
|
||||
instant = instant.plusMillis(millisToAdd);
|
||||
}
|
||||
|
||||
public void plusNanos(final long nanosToAdd) {
|
||||
instant = instant.plusNanos(nanosToAdd);
|
||||
}
|
||||
|
||||
public void plusSeconds(final long secondsToAdd) {
|
||||
instant = instant.plusSeconds(secondsToAdd);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ZoneId getZone() {
|
||||
return zone;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Clock withZone(final ZoneId zone) {
|
||||
if (zone.equals(this.zone)) {
|
||||
return this;
|
||||
}
|
||||
return new ModifiableFixedTimeClock(instant, zone);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long millis() {
|
||||
return instant.toEpochMilli();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Instant instant() {
|
||||
return instant;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(final Object obj) {
|
||||
if (obj instanceof ModifiableFixedTimeClock) {
|
||||
final ModifiableFixedTimeClock other = (ModifiableFixedTimeClock) obj;
|
||||
return instant.equals(other.instant) && zone.equals(other.zone);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return instant.hashCode() ^ zone.hashCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "FixedClock[" + instant + "," + zone + "]";
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user