/*
* Copyright 2015 Ben Manes. All Rights Reserved.
*
* 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 com.github.benmanes.caffeine.cache.issues;
import static com.github.benmanes.caffeine.testing.IsFutureValue.futureOf;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import org.testng.annotations.AfterClass;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Listeners;
import org.testng.annotations.Test;
import com.github.benmanes.caffeine.cache.AsyncCacheLoader;
import com.github.benmanes.caffeine.cache.AsyncLoadingCache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.testing.CacheValidationListener;
import com.google.common.util.concurrent.MoreExecutors;
/**
* Issue #30: Unexpected cache misses with <tt>expireAfterWrite</tt> using multiple keys.
* <p>
* Prior to eviction, the cache must revalidate that the entry has expired. If the entry was updated
* but the maintenance thread reads a stale value, then the entry may be prematurely expired. The
* removal must detect that the entry was "resurrected" and cancel the expiration.
*
* @author yurgis2
* @author ben.manes@gmail.com (Ben Manes)
*/
@Test(groups = "isolated")
@Listeners(CacheValidationListener.class)
public final class Issue30Test {
private static final boolean DEBUG = false;
private static final String A_KEY = "foo";
private static final String A_ORIGINAL = "foo0";
private static final String A_UPDATE_1 = "foo1";
private static final String A_UPDATE_2 = "foo2";
private static final String B_KEY = "bar";
private static final String B_ORIGINAL = "bar0";
private static final String B_UPDATE_1 = "bar1";
private static final String B_UPDATE_2 = "bar2";
private static final int TTL = 100;
private static final int EPSILON = 10;
private static final int N_THREADS = 10;
private final ExecutorService executor = Executors.newFixedThreadPool(N_THREADS);
@AfterClass
public void afterClass() {
MoreExecutors.shutdownAndAwaitTermination(executor, 1, TimeUnit.MINUTES);
}
@DataProvider(name = "params")
public Object[][] providesCache() {
ConcurrentMap<String, String> source = new ConcurrentHashMap<>();
ConcurrentMap<String, Date> lastLoad = new ConcurrentHashMap<>();
AsyncLoadingCache<String, String> cache = Caffeine.newBuilder()
.expireAfterWrite(TTL, TimeUnit.MILLISECONDS)
.executor(executor)
.buildAsync(new Loader(source, lastLoad));
return new Object[][] {{ cache, source, lastLoad }};
}
@Test(dataProvider = "params", invocationCount = 100, threadPoolSize = N_THREADS)
public void expiration(AsyncLoadingCache<String, String> cache,
ConcurrentMap<String, String> source, ConcurrentMap<String, Date> lastLoad) throws Exception {
initialValues(cache, source, lastLoad);
firstUpdate(cache, source);
secondUpdate(cache, source);
}
private void initialValues(AsyncLoadingCache<String, String> cache,
ConcurrentMap<String, String> source, ConcurrentMap<String, Date> lastLoad)
throws InterruptedException, ExecutionException {
source.put(A_KEY, A_ORIGINAL);
source.put(B_KEY, B_ORIGINAL);
lastLoad.clear();
assertThat("should serve initial value", cache.get(A_KEY), is(futureOf(A_ORIGINAL)));
assertThat("should serve initial value", cache.get(B_KEY), is(futureOf(B_ORIGINAL)));
}
private void firstUpdate(AsyncLoadingCache<String, String> cache,
ConcurrentMap<String, String> source) throws InterruptedException, ExecutionException {
source.put(A_KEY, A_UPDATE_1);
source.put(B_KEY, B_UPDATE_1);
assertThat("should serve cached initial value", cache.get(A_KEY), is(futureOf(A_ORIGINAL)));
assertThat("should serve cached initial value", cache.get(B_KEY), is(futureOf(B_ORIGINAL)));
Thread.sleep(EPSILON); // sleep for less than expiration
assertThat("still serve cached initial value", cache.get(A_KEY), is(futureOf(A_ORIGINAL)));
assertThat("still serve cached initial value", cache.get(B_KEY), is(futureOf(B_ORIGINAL)));
Thread.sleep(TTL + EPSILON); // sleep until expiration
assertThat("now serve first updated value", cache.get(A_KEY), is(futureOf(A_UPDATE_1)));
assertThat("now serve first updated value", cache.get(B_KEY), is(futureOf(B_UPDATE_1)));
}
private void secondUpdate(AsyncLoadingCache<String, String> cache,
ConcurrentMap<String, String> source) throws Exception {
source.put(A_KEY, A_UPDATE_2);
source.put(B_KEY, B_UPDATE_2);
assertThat("serve cached first updated value", cache.get(A_KEY), is(futureOf(A_UPDATE_1)));
assertThat("serve cached first updated value", cache.get(B_KEY), is(futureOf(B_UPDATE_1)));
Thread.sleep(EPSILON); // sleep for less than expiration
assertThat("serve cached first updated value", cache.get(A_KEY), is(futureOf(A_UPDATE_1)));
assertThat("serve cached first updated value", cache.get(A_KEY), is(futureOf(A_UPDATE_1)));
}
static final class Loader implements AsyncCacheLoader<String, String> {
final ConcurrentMap<String, String> source;
final ConcurrentMap<String, Date> lastLoad;
Loader(ConcurrentMap<String, String> source, ConcurrentMap<String, Date> lastLoad) {
this.source = source;
this.lastLoad = lastLoad;
}
@Override
public CompletableFuture<String> asyncLoad(String key, Executor executor) {
reportCacheMiss(key);
return CompletableFuture.completedFuture(source.get(key));
}
private void reportCacheMiss(String key) {
Date now = new Date();
Date last = lastLoad.get(key);
lastLoad.put(key, now);
if (DEBUG) {
String time = new SimpleDateFormat("hh:MM:ss.SSS").format(new Date());
if (last == null) {
System.out.println(key + ": first load @ " + time);
} else {
long duration = (now.getTime() - last.getTime());
System.out.println(key + ": " + duration + "ms after last load @ " + time);
}
}
}
}
}