mirror of https://github.com/grpc/grpc-java.git
rls: LruCache interface and implementation (#6799)
This commit is contained in:
parent
833a3ff293
commit
4974b51c53
|
|
@ -0,0 +1,386 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2020 The gRPC Authors
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.grpc.rls.internal;
|
||||||
|
|
||||||
|
import static com.google.common.base.Preconditions.checkArgument;
|
||||||
|
import static com.google.common.base.Preconditions.checkNotNull;
|
||||||
|
import static com.google.common.base.Preconditions.checkState;
|
||||||
|
|
||||||
|
import com.google.common.base.MoreObjects;
|
||||||
|
import io.grpc.internal.TimeProvider;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.ScheduledFuture;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
import javax.annotation.CheckReturnValue;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
import javax.annotation.concurrent.GuardedBy;
|
||||||
|
import javax.annotation.concurrent.ThreadSafe;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A LinkedHashLruCache implements least recently used caching where it supports access order lru
|
||||||
|
* cache eviction while allowing entry level expiration time. When the cache reaches max capacity,
|
||||||
|
* LruCache try to remove up to one already expired entries. If it doesn't find any expired entries,
|
||||||
|
* it will remove based on access order of entry. On top of this, LruCache also proactively removes
|
||||||
|
* expired entries based on configured time interval.
|
||||||
|
*/
|
||||||
|
@ThreadSafe
|
||||||
|
abstract class LinkedHashLruCache<K, V> implements LruCache<K, V> {
|
||||||
|
|
||||||
|
private final Object lock = new Object();
|
||||||
|
|
||||||
|
@GuardedBy("lock")
|
||||||
|
private final LinkedHashMap<K, SizedValue> delegate;
|
||||||
|
private final PeriodicCleaner periodicCleaner;
|
||||||
|
private final TimeProvider timeProvider;
|
||||||
|
private final EvictionListener<K, SizedValue> evictionListener;
|
||||||
|
private final AtomicLong estimatedSizeBytes = new AtomicLong();
|
||||||
|
private long estimatedMaxSizeBytes;
|
||||||
|
|
||||||
|
LinkedHashLruCache(
|
||||||
|
final long estimatedMaxSizeBytes,
|
||||||
|
@Nullable final EvictionListener<K, V> evictionListener,
|
||||||
|
int cleaningInterval,
|
||||||
|
TimeUnit cleaningIntervalUnit,
|
||||||
|
ScheduledExecutorService ses,
|
||||||
|
final TimeProvider timeProvider) {
|
||||||
|
checkState(estimatedMaxSizeBytes > 0, "max estimated cache size should be positive");
|
||||||
|
this.estimatedMaxSizeBytes = estimatedMaxSizeBytes;
|
||||||
|
this.evictionListener = new SizeHandlingEvictionListener(evictionListener);
|
||||||
|
this.timeProvider = checkNotNull(timeProvider, "timeProvider");
|
||||||
|
delegate = new LinkedHashMap<K, SizedValue>(
|
||||||
|
// rough estimate or minimum hashmap default
|
||||||
|
Math.max((int) (estimatedMaxSizeBytes / 1000), 16),
|
||||||
|
/* loadFactor= */ 0.75f,
|
||||||
|
/* accessOrder= */ true) {
|
||||||
|
@Override
|
||||||
|
protected boolean removeEldestEntry(Map.Entry<K, SizedValue> eldest) {
|
||||||
|
if (estimatedSizeBytes.get() <= LinkedHashLruCache.this.estimatedMaxSizeBytes) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// first, remove at most 1 expired entry
|
||||||
|
boolean removed = cleanupExpiredEntries(1, timeProvider.currentTimeNanos());
|
||||||
|
// handles size based eviction if necessary no expired entry
|
||||||
|
boolean shouldRemove =
|
||||||
|
!removed && shouldInvalidateEldestEntry(eldest.getKey(), eldest.getValue().value);
|
||||||
|
if (shouldRemove) {
|
||||||
|
// remove entry by us to make sure lruIterator and cache is in sync
|
||||||
|
LinkedHashLruCache.this.invalidate(eldest.getKey(), EvictionType.SIZE);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
periodicCleaner = new PeriodicCleaner(ses, cleaningInterval, cleaningIntervalUnit).start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if the eldest entry should be kept or not when the cache size limit is reached. Note
|
||||||
|
* that LruCache is access level and the eldest is determined by access pattern.
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unused")
|
||||||
|
protected boolean shouldInvalidateEldestEntry(K eldestKey, V eldestValue) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Determines if the entry is already expired or not. */
|
||||||
|
protected abstract boolean isExpired(K key, V value, long nowNanos);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns estimated size of entry to keep track. If it always returns 1, the max size bytes
|
||||||
|
* behaves like max number of entry (default behavior).
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unused")
|
||||||
|
protected int estimateSizeOf(K key, V value) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Updates size for given key if entry exists. It is useful if the cache value is mutated. */
|
||||||
|
public void updateEntrySize(K key) {
|
||||||
|
synchronized (lock) {
|
||||||
|
SizedValue entry = readInternal(key);
|
||||||
|
if (entry == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int prevSize = entry.size;
|
||||||
|
int newSize = estimateSizeOf(key, entry.value);
|
||||||
|
entry.size = newSize;
|
||||||
|
estimatedSizeBytes.addAndGet(newSize - prevSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Nullable
|
||||||
|
public final V cache(K key, V value) {
|
||||||
|
checkNotNull(key, "key");
|
||||||
|
checkNotNull(value, "value");
|
||||||
|
SizedValue existing;
|
||||||
|
int size = estimateSizeOf(key, value);
|
||||||
|
synchronized (lock) {
|
||||||
|
estimatedSizeBytes.addAndGet(size);
|
||||||
|
existing = delegate.put(key, new SizedValue(size, value));
|
||||||
|
if (existing != null) {
|
||||||
|
evictionListener.onEviction(key, existing, EvictionType.REPLACED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return existing == null ? null : existing.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Nullable
|
||||||
|
@CheckReturnValue
|
||||||
|
public final V read(K key) {
|
||||||
|
SizedValue entry = readInternal(key);
|
||||||
|
if (entry != null) {
|
||||||
|
return entry.value;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@CheckReturnValue
|
||||||
|
private SizedValue readInternal(K key) {
|
||||||
|
checkNotNull(key, "key");
|
||||||
|
synchronized (lock) {
|
||||||
|
SizedValue existing = delegate.get(key);
|
||||||
|
if (existing != null && isExpired(key, existing.value, timeProvider.currentTimeNanos())) {
|
||||||
|
invalidate(key, EvictionType.EXPIRED);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Nullable
|
||||||
|
public final V invalidate(K key) {
|
||||||
|
return invalidate(key, EvictionType.EXPLICIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private V invalidate(K key, EvictionType cause) {
|
||||||
|
checkNotNull(key, "key");
|
||||||
|
checkNotNull(cause, "cause");
|
||||||
|
synchronized (lock) {
|
||||||
|
SizedValue existing = delegate.remove(key);
|
||||||
|
if (existing != null) {
|
||||||
|
evictionListener.onEviction(key, existing, cause);
|
||||||
|
}
|
||||||
|
return existing == null ? null : existing.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public final void invalidateAll(Iterable<K> keys) {
|
||||||
|
checkNotNull(keys, "keys");
|
||||||
|
synchronized (lock) {
|
||||||
|
for (K key : keys) {
|
||||||
|
SizedValue existing = delegate.remove(key);
|
||||||
|
if (existing != null) {
|
||||||
|
evictionListener.onEviction(key, existing, EvictionType.EXPLICIT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@CheckReturnValue
|
||||||
|
public final boolean hasCacheEntry(K key) {
|
||||||
|
// call readInternal to filter already expired entry in the cache
|
||||||
|
return readInternal(key) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns shallow copied values in the cache. */
|
||||||
|
public final List<V> values() {
|
||||||
|
synchronized (lock) {
|
||||||
|
List<V> list = new ArrayList<>(delegate.size());
|
||||||
|
for (SizedValue value : delegate.values()) {
|
||||||
|
list.add(value.value);
|
||||||
|
}
|
||||||
|
return Collections.unmodifiableList(list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resizes cache. If new size is smaller than current estimated size, it will free up space by
|
||||||
|
* removing expired entries and removing oldest entries by LRU order.
|
||||||
|
*/
|
||||||
|
public final void resize(int newSizeBytes) {
|
||||||
|
long now = timeProvider.currentTimeNanos();
|
||||||
|
synchronized (lock) {
|
||||||
|
long estimatedSizeBytesCopy = estimatedMaxSizeBytes;
|
||||||
|
this.estimatedMaxSizeBytes = newSizeBytes;
|
||||||
|
if (estimatedSizeBytesCopy <= newSizeBytes) {
|
||||||
|
// new size is larger no need to do cleanup
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// cleanup expired entries
|
||||||
|
cleanupExpiredEntries(now);
|
||||||
|
|
||||||
|
// cleanup eldest entry until new size limit
|
||||||
|
Iterator<Map.Entry<K, SizedValue>> lruIter = delegate.entrySet().iterator();
|
||||||
|
while (lruIter.hasNext() && estimatedMaxSizeBytes > this.estimatedSizeBytes.get()) {
|
||||||
|
Map.Entry<K, SizedValue> entry = lruIter.next();
|
||||||
|
lruIter.remove();
|
||||||
|
// eviction listener will update the estimatedSizeBytes
|
||||||
|
evictionListener.onEviction(entry.getKey(), entry.getValue(), EvictionType.SIZE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@CheckReturnValue
|
||||||
|
public final int estimatedSize() {
|
||||||
|
synchronized (lock) {
|
||||||
|
return delegate.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean cleanupExpiredEntries(long now) {
|
||||||
|
return cleanupExpiredEntries(Integer.MAX_VALUE, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxExpiredEntries is by number of entries
|
||||||
|
private boolean cleanupExpiredEntries(int maxExpiredEntries, long now) {
|
||||||
|
checkArgument(maxExpiredEntries > 0, "maxExpiredEntries must be positive");
|
||||||
|
boolean removedAny = false;
|
||||||
|
synchronized (lock) {
|
||||||
|
Iterator<Map.Entry<K, SizedValue>> lruIter = delegate.entrySet().iterator();
|
||||||
|
while (lruIter.hasNext() && maxExpiredEntries > 0) {
|
||||||
|
Map.Entry<K, SizedValue> entry = lruIter.next();
|
||||||
|
if (isExpired(entry.getKey(), entry.getValue().value, now)) {
|
||||||
|
lruIter.remove();
|
||||||
|
evictionListener.onEviction(entry.getKey(), entry.getValue(), EvictionType.EXPIRED);
|
||||||
|
removedAny = true;
|
||||||
|
maxExpiredEntries--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return removedAny;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public final void close() {
|
||||||
|
synchronized (lock) {
|
||||||
|
periodicCleaner.stop();
|
||||||
|
doClose();
|
||||||
|
delegate.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void doClose() {}
|
||||||
|
|
||||||
|
/** Periodically cleans up the AsyncRequestCache. */
|
||||||
|
private final class PeriodicCleaner {
|
||||||
|
|
||||||
|
private final ScheduledExecutorService ses;
|
||||||
|
private final int interval;
|
||||||
|
private final TimeUnit intervalUnit;
|
||||||
|
private ScheduledFuture<?> scheduledFuture;
|
||||||
|
|
||||||
|
PeriodicCleaner(ScheduledExecutorService ses, int interval, TimeUnit intervalUnit) {
|
||||||
|
this.ses = checkNotNull(ses, "ses");
|
||||||
|
checkState(interval > 0, "interval must be positive");
|
||||||
|
this.interval = interval;
|
||||||
|
this.intervalUnit = checkNotNull(intervalUnit, "intervalUnit");
|
||||||
|
}
|
||||||
|
|
||||||
|
PeriodicCleaner start() {
|
||||||
|
checkState(scheduledFuture == null, "cleaning task can be started only once");
|
||||||
|
this.scheduledFuture =
|
||||||
|
ses.scheduleAtFixedRate(new CleaningTask(), interval, interval, intervalUnit);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
void stop() {
|
||||||
|
if (scheduledFuture != null) {
|
||||||
|
scheduledFuture.cancel(false);
|
||||||
|
scheduledFuture = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class CleaningTask implements Runnable {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
cleanupExpiredEntries(timeProvider.currentTimeNanos());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A {@link EvictionListener} keeps track of size. */
|
||||||
|
private final class SizeHandlingEvictionListener implements EvictionListener<K, SizedValue> {
|
||||||
|
|
||||||
|
private final EvictionListener<K, V> delegate;
|
||||||
|
|
||||||
|
SizeHandlingEvictionListener(@Nullable EvictionListener<K, V> delegate) {
|
||||||
|
this.delegate = delegate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onEviction(K key, SizedValue value, EvictionType cause) {
|
||||||
|
estimatedSizeBytes.addAndGet(-1 * value.size);
|
||||||
|
if (delegate != null) {
|
||||||
|
delegate.onEviction(key, value.value, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class SizedValue {
|
||||||
|
volatile int size;
|
||||||
|
final V value;
|
||||||
|
|
||||||
|
SizedValue(int size, V value) {
|
||||||
|
this.size = size;
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
// NOTE: the size doesn't affect equality
|
||||||
|
if (this == o) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (o == null || getClass() != o.getClass()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
LinkedHashLruCache<?, ?>.SizedValue that = (LinkedHashLruCache<?, ?>.SizedValue) o;
|
||||||
|
return Objects.equals(value, that.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
// NOTE: the size doesn't affect hashCode
|
||||||
|
return Objects.hash(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return MoreObjects.toStringHelper(this)
|
||||||
|
.add("size", size)
|
||||||
|
.add("value", value)
|
||||||
|
.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2020 The gRPC Authors
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.grpc.rls.internal;
|
||||||
|
|
||||||
|
import javax.annotation.CheckReturnValue;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
|
||||||
|
/** An LruCache is a cache with least recently used eviction. */
|
||||||
|
interface LruCache<K, V> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Populates a cache entry. If the cache entry for given key already exists, the value will be
|
||||||
|
* replaced to the new value.
|
||||||
|
*
|
||||||
|
* @return the previous value associated with key, otherwise {@code null}
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
V cache(K key, V value);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns cached value for given key if exists, otherwise {@code null}. This operation doesn't
|
||||||
|
* return already expired cache entry.
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
@CheckReturnValue
|
||||||
|
V read(K key);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidates an entry for given key if exists. This operation will trigger {@link
|
||||||
|
* EvictionListener} with {@link EvictionType#EXPLICIT}.
|
||||||
|
*
|
||||||
|
* @return the previous value associated with key, otherwise {@code null}
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
V invalidate(K key);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidates cache entries for given keys. This operation will trigger {@link EvictionListener}
|
||||||
|
* with {@link EvictionType#EXPLICIT}.
|
||||||
|
*/
|
||||||
|
void invalidateAll(Iterable<K> keys);
|
||||||
|
|
||||||
|
/** Returns {@code true} if given key is cached. */
|
||||||
|
@CheckReturnValue
|
||||||
|
boolean hasCacheEntry(K key);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the estimated number of entry of the cache. Note that the size can be larger than its
|
||||||
|
* true size, because there might be already expired cache.
|
||||||
|
*/
|
||||||
|
@CheckReturnValue
|
||||||
|
int estimatedSize();
|
||||||
|
|
||||||
|
/** Closes underlying resources. */
|
||||||
|
void close();
|
||||||
|
|
||||||
|
/** A Listener notifies cache eviction events. */
|
||||||
|
interface EvictionListener<K, V> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifies the listener when any cache entry is evicted. Implementation can assume that this
|
||||||
|
* method is called serially. Implementation should be non blocking, for long running task
|
||||||
|
* consider offloading the task to {@link java.util.concurrent.Executor}.
|
||||||
|
*/
|
||||||
|
void onEviction(K key, V value, EvictionType cause);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Type of cache eviction. */
|
||||||
|
enum EvictionType {
|
||||||
|
/** Explicitly removed by user. */
|
||||||
|
EXPLICIT,
|
||||||
|
/** Evicted due to size limit. */
|
||||||
|
SIZE,
|
||||||
|
/** Evicted due to entry expired. */
|
||||||
|
EXPIRED,
|
||||||
|
/** Removed due to error. */
|
||||||
|
ERROR,
|
||||||
|
/** Evicted by replacement. */
|
||||||
|
REPLACED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,255 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2020 The gRPC Authors
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.grpc.rls.internal;
|
||||||
|
|
||||||
|
import static com.google.common.base.Preconditions.checkArgument;
|
||||||
|
import static com.google.common.base.Preconditions.checkNotNull;
|
||||||
|
import static com.google.common.base.Preconditions.checkState;
|
||||||
|
import static com.google.common.truth.Truth.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
|
import io.grpc.internal.TimeProvider;
|
||||||
|
import io.grpc.rls.internal.LruCache.EvictionListener;
|
||||||
|
import io.grpc.rls.internal.LruCache.EvictionType;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.ScheduledFuture;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import org.junit.Before;
|
||||||
|
import org.junit.Rule;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.junit.runner.RunWith;
|
||||||
|
import org.junit.runners.JUnit4;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.MockitoJUnit;
|
||||||
|
import org.mockito.junit.MockitoRule;
|
||||||
|
|
||||||
|
@RunWith(JUnit4.class)
|
||||||
|
public class LinkedHashLruCacheTest {
|
||||||
|
|
||||||
|
private static final int MAX_SIZE = 5;
|
||||||
|
|
||||||
|
@Rule
|
||||||
|
public final MockitoRule mocks = MockitoJUnit.rule();
|
||||||
|
|
||||||
|
private final DoNotUseFakeScheduledService fakeScheduledService =
|
||||||
|
mock(DoNotUseFakeScheduledService.class, CALLS_REAL_METHODS);
|
||||||
|
private final TimeProvider timeProvider = fakeScheduledService.getFakeTicker();
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private EvictionListener<Integer, Entry> evictionListener;
|
||||||
|
private LinkedHashLruCache<Integer, Entry> cache;
|
||||||
|
|
||||||
|
@Before
|
||||||
|
public void setUp() {
|
||||||
|
this.cache = new LinkedHashLruCache<Integer, Entry>(
|
||||||
|
MAX_SIZE,
|
||||||
|
evictionListener,
|
||||||
|
10,
|
||||||
|
TimeUnit.NANOSECONDS,
|
||||||
|
fakeScheduledService,
|
||||||
|
timeProvider) {
|
||||||
|
@Override
|
||||||
|
protected boolean isExpired(Integer key, Entry value, long nowNanos) {
|
||||||
|
return value.expireTime <= nowNanos;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void eviction_size() {
|
||||||
|
for (int i = 1; i <= MAX_SIZE; i++) {
|
||||||
|
cache.cache(i, new Entry("Entry" + i, Long.MAX_VALUE));
|
||||||
|
}
|
||||||
|
cache.cache(MAX_SIZE + 1, new Entry("should kick the first", Long.MAX_VALUE));
|
||||||
|
|
||||||
|
verify(evictionListener).onEviction(1, new Entry("Entry1", Long.MAX_VALUE), EvictionType.SIZE);
|
||||||
|
assertThat(cache.estimatedSize()).isEqualTo(MAX_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void size() {
|
||||||
|
Entry entry1 = new Entry("Entry0", timeProvider.currentTimeNanos() + 10);
|
||||||
|
Entry entry2 = new Entry("Entry1", timeProvider.currentTimeNanos() + 20);
|
||||||
|
cache.cache(0, entry1);
|
||||||
|
cache.cache(1, entry2);
|
||||||
|
assertThat(cache.estimatedSize()).isEqualTo(2);
|
||||||
|
|
||||||
|
assertThat(cache.invalidate(0)).isEqualTo(entry1);
|
||||||
|
assertThat(cache.estimatedSize()).isEqualTo(1);
|
||||||
|
|
||||||
|
assertThat(cache.invalidate(1)).isEqualTo(entry2);
|
||||||
|
assertThat(cache.estimatedSize()).isEqualTo(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void eviction_expire() {
|
||||||
|
Entry toBeEvicted = new Entry("Entry0", timeProvider.currentTimeNanos() + 10);
|
||||||
|
Entry survivor = new Entry("Entry1", timeProvider.currentTimeNanos() + 20);
|
||||||
|
cache.cache(0, toBeEvicted);
|
||||||
|
cache.cache(1, survivor);
|
||||||
|
|
||||||
|
fakeScheduledService.advance(10, TimeUnit.NANOSECONDS);
|
||||||
|
verify(evictionListener).onEviction(0, toBeEvicted, EvictionType.EXPIRED);
|
||||||
|
|
||||||
|
fakeScheduledService.advance(10, TimeUnit.NANOSECONDS);
|
||||||
|
verify(evictionListener).onEviction(1, survivor, EvictionType.EXPIRED);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void eviction_explicit() {
|
||||||
|
Entry toBeEvicted = new Entry("Entry0", timeProvider.currentTimeNanos() + 10);
|
||||||
|
Entry survivor = new Entry("Entry1", timeProvider.currentTimeNanos() + 20);
|
||||||
|
cache.cache(0, toBeEvicted);
|
||||||
|
cache.cache(1, survivor);
|
||||||
|
|
||||||
|
assertThat(cache.invalidate(0)).isEqualTo(toBeEvicted);
|
||||||
|
|
||||||
|
verify(evictionListener).onEviction(0, toBeEvicted, EvictionType.EXPLICIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void eviction_replaced() {
|
||||||
|
Entry toBeEvicted = new Entry("Entry0", timeProvider.currentTimeNanos() + 10);
|
||||||
|
Entry survivor = new Entry("Entry1", timeProvider.currentTimeNanos() + 20);
|
||||||
|
cache.cache(0, toBeEvicted);
|
||||||
|
cache.cache(0, survivor);
|
||||||
|
|
||||||
|
verify(evictionListener).onEviction(0, toBeEvicted, EvictionType.REPLACED);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void eviction_size_shouldEvictAlreadyExpired() {
|
||||||
|
for (int i = 1; i <= MAX_SIZE; i++) {
|
||||||
|
// last two entries are <= current time (already expired)
|
||||||
|
cache.cache(i, new Entry("Entry" + i, timeProvider.currentTimeNanos() + MAX_SIZE - i - 1));
|
||||||
|
}
|
||||||
|
cache.cache(MAX_SIZE + 1, new Entry("should kick the first", Long.MAX_VALUE));
|
||||||
|
|
||||||
|
// should remove MAX_SIZE-1 instead of MAX_SIZE because MAX_SIZE is accessed later
|
||||||
|
verify(evictionListener)
|
||||||
|
.onEviction(eq(MAX_SIZE - 1), any(Entry.class), eq(EvictionType.EXPIRED));
|
||||||
|
assertThat(cache.estimatedSize()).isEqualTo(MAX_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void eviction_get_shouldNotReturnAlreadyExpired() {
|
||||||
|
for (int i = 1; i <= MAX_SIZE; i++) {
|
||||||
|
// last entry is already expired when added
|
||||||
|
cache.cache(i, new Entry("Entry" + i, timeProvider.currentTimeNanos() + MAX_SIZE - i));
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThat(cache.estimatedSize()).isEqualTo(MAX_SIZE);
|
||||||
|
assertThat(cache.read(MAX_SIZE)).isNull();
|
||||||
|
assertThat(cache.estimatedSize()).isEqualTo(MAX_SIZE - 1);
|
||||||
|
verify(evictionListener).onEviction(eq(MAX_SIZE), any(Entry.class), eq(EvictionType.EXPIRED));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class Entry {
|
||||||
|
String value;
|
||||||
|
long expireTime;
|
||||||
|
|
||||||
|
Entry(String value, long expireTime) {
|
||||||
|
this.value = value;
|
||||||
|
this.expireTime = expireTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (this == o) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (o == null || getClass() != o.getClass()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Entry entry = (Entry) o;
|
||||||
|
return expireTime == entry.expireTime && Objects.equals(value, entry.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return Objects.hash(value, expireTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fake minimal implementation of ScheduledExecutorService *only* supports scheduledAtFixedRate
|
||||||
|
* with a lot of limitation / assumptions. Only intended to be used in this test with
|
||||||
|
* CALL_REAL_METHODS mock.
|
||||||
|
*/
|
||||||
|
private abstract static class DoNotUseFakeScheduledService implements ScheduledExecutorService {
|
||||||
|
|
||||||
|
private long currTimeNanos;
|
||||||
|
private long period;
|
||||||
|
private long nextRun;
|
||||||
|
private AtomicReference<Runnable> command;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public final ScheduledFuture<?> scheduleAtFixedRate(
|
||||||
|
Runnable command, long initialDelay, long period, TimeUnit unit) {
|
||||||
|
// hack to initialize
|
||||||
|
if (this.command == null) {
|
||||||
|
this.command = new AtomicReference<>();
|
||||||
|
}
|
||||||
|
checkState(this.command.get() == null, "only can schedule one");
|
||||||
|
checkState(period > 0, "period should be positive");
|
||||||
|
checkState(initialDelay >= 0, "initial delay should be >= 0");
|
||||||
|
if (initialDelay == 0) {
|
||||||
|
initialDelay = period;
|
||||||
|
command.run();
|
||||||
|
}
|
||||||
|
this.command.set(checkNotNull(command, "command"));
|
||||||
|
this.nextRun = checkNotNull(unit, "unit").toNanos(initialDelay) + currTimeNanos;
|
||||||
|
this.period = unit.toNanos(period);
|
||||||
|
return mock(ScheduledFuture.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
TimeProvider getFakeTicker() {
|
||||||
|
return new TimeProvider() {
|
||||||
|
@Override
|
||||||
|
public long currentTimeNanos() {
|
||||||
|
return currTimeNanos;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
void advance(long delta, TimeUnit unit) {
|
||||||
|
// if scheduled command, only can advance the ticker to trigger at most 1 event
|
||||||
|
boolean scheduled = command != null && command.get() != null;
|
||||||
|
long deltaNanos = unit.toNanos(delta);
|
||||||
|
if (scheduled) {
|
||||||
|
checkArgument(
|
||||||
|
(this.currTimeNanos + deltaNanos) < (nextRun + 2 * period),
|
||||||
|
"Cannot advance ticker because more than one repeated tasks will run");
|
||||||
|
long finalTime = this.currTimeNanos + deltaNanos;
|
||||||
|
if (finalTime >= nextRun) {
|
||||||
|
nextRun += period;
|
||||||
|
this.currTimeNanos = nextRun;
|
||||||
|
command.get().run();
|
||||||
|
}
|
||||||
|
this.currTimeNanos = finalTime;
|
||||||
|
} else {
|
||||||
|
this.currTimeNanos += deltaNanos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue