/* * 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); } } } } }