diff --git a/core-api/src/main/java/com/optimizely/ab/internal/Cache.java b/core-api/src/main/java/com/optimizely/ab/internal/Cache.java new file mode 100644 index 000000000..ba667ebd2 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/internal/Cache.java @@ -0,0 +1,25 @@ +/** + * + * Copyright 2022, Optimizely + * + * 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.optimizely.ab.internal; + +public interface Cache { + int DEFAULT_MAX_SIZE = 10000; + int DEFAULT_TIMEOUT_SECONDS = 600; + void save(String key, T value); + T lookup(String key); + void reset(); +} diff --git a/core-api/src/main/java/com/optimizely/ab/internal/DefaultLRUCache.java b/core-api/src/main/java/com/optimizely/ab/internal/DefaultLRUCache.java new file mode 100644 index 000000000..a531c5c83 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/internal/DefaultLRUCache.java @@ -0,0 +1,96 @@ +/** + * + * Copyright 2022, Optimizely + * + * 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.optimizely.ab.internal; + +import com.optimizely.ab.annotations.VisibleForTesting; + +import java.util.*; + +public class DefaultLRUCache implements Cache { + + private final Object lock = new Object(); + + private final Integer maxSize; + + private final Long timeoutMillis; + + @VisibleForTesting + final LinkedHashMap linkedHashMap = new LinkedHashMap(16, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return this.size() > maxSize; + } + }; + + public DefaultLRUCache() { + this(DEFAULT_MAX_SIZE, DEFAULT_TIMEOUT_SECONDS); + } + + public DefaultLRUCache(Integer maxSize, Integer timeoutSeconds) { + this.maxSize = maxSize < 0 ? Integer.valueOf(0) : maxSize; + this.timeoutMillis = (timeoutSeconds < 0) ? 0 : (timeoutSeconds * 1000L); + } + + public void save(String key, T value) { + if (maxSize == 0) { + // Cache is disabled when maxSize = 0 + return; + } + + synchronized (lock) { + linkedHashMap.put(key, new CacheEntity(value)); + } + } + + public T lookup(String key) { + if (maxSize == 0) { + // Cache is disabled when maxSize = 0 + return null; + } + + synchronized (lock) { + if (linkedHashMap.containsKey(key)) { + CacheEntity entity = linkedHashMap.get(key); + Long nowMs = new Date().getTime(); + + // ttl = 0 means entities never expire. + if (timeoutMillis == 0 || (nowMs - entity.timestamp < timeoutMillis)) { + return entity.value; + } + + linkedHashMap.remove(key); + } + return null; + } + } + + public void reset() { + synchronized (lock) { + linkedHashMap.clear(); + } + } + + private class CacheEntity { + public T value; + public Long timestamp; + + public CacheEntity(T value) { + this.value = value; + this.timestamp = new Date().getTime(); + } + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/internal/DefaultLRUCacheTest.java b/core-api/src/test/java/com/optimizely/ab/internal/DefaultLRUCacheTest.java new file mode 100644 index 000000000..79aa96ff3 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/internal/DefaultLRUCacheTest.java @@ -0,0 +1,172 @@ +/** + * + * Copyright 2022, Optimizely + * + * 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.optimizely.ab.internal; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.Assert.*; + +public class DefaultLRUCacheTest { + + @Test + public void createSaveAndLookupOneItem() { + Cache cache = new DefaultLRUCache<>(); + assertNull(cache.lookup("key1")); + cache.save("key1", "value1"); + assertEquals("value1", cache.lookup("key1")); + } + + @Test + public void saveAndLookupMultipleItems() { + DefaultLRUCache> cache = new DefaultLRUCache<>(); + + cache.save("user1", Arrays.asList("segment1", "segment2")); + cache.save("user2", Arrays.asList("segment3", "segment4")); + cache.save("user3", Arrays.asList("segment5", "segment6")); + + String[] itemKeys = cache.linkedHashMap.keySet().toArray(new String[0]); + assertEquals("user1", itemKeys[0]); + assertEquals("user2", itemKeys[1]); + assertEquals("user3", itemKeys[2]); + + assertEquals(Arrays.asList("segment1", "segment2"), cache.lookup("user1")); + + itemKeys = cache.linkedHashMap.keySet().toArray(new String[0]); + // Lookup should move user1 to bottom of the list and push up others. + assertEquals("user2", itemKeys[0]); + assertEquals("user3", itemKeys[1]); + assertEquals("user1", itemKeys[2]); + + assertEquals(Arrays.asList("segment3", "segment4"), cache.lookup("user2")); + + itemKeys = cache.linkedHashMap.keySet().toArray(new String[0]); + // Lookup should move user2 to bottom of the list and push up others. + assertEquals("user3", itemKeys[0]); + assertEquals("user1", itemKeys[1]); + assertEquals("user2", itemKeys[2]); + + assertEquals(Arrays.asList("segment5", "segment6"), cache.lookup("user3")); + + itemKeys = cache.linkedHashMap.keySet().toArray(new String[0]); + // Lookup should move user3 to bottom of the list and push up others. + assertEquals("user1", itemKeys[0]); + assertEquals("user2", itemKeys[1]); + assertEquals("user3", itemKeys[2]); + } + + @Test + public void saveShouldReorderList() { + DefaultLRUCache> cache = new DefaultLRUCache<>(); + + cache.save("user1", Arrays.asList("segment1", "segment2")); + cache.save("user2", Arrays.asList("segment3", "segment4")); + cache.save("user3", Arrays.asList("segment5", "segment6")); + + String[] itemKeys = cache.linkedHashMap.keySet().toArray(new String[0]); + assertEquals("user1", itemKeys[0]); + assertEquals("user2", itemKeys[1]); + assertEquals("user3", itemKeys[2]); + + cache.save("user1", Arrays.asList("segment1", "segment2")); + + itemKeys = cache.linkedHashMap.keySet().toArray(new String[0]); + // save should move user1 to bottom of the list and push up others. + assertEquals("user2", itemKeys[0]); + assertEquals("user3", itemKeys[1]); + assertEquals("user1", itemKeys[2]); + + cache.save("user2", Arrays.asList("segment3", "segment4")); + + itemKeys = cache.linkedHashMap.keySet().toArray(new String[0]); + // save should move user2 to bottom of the list and push up others. + assertEquals("user3", itemKeys[0]); + assertEquals("user1", itemKeys[1]); + assertEquals("user2", itemKeys[2]); + + cache.save("user3", Arrays.asList("segment5", "segment6")); + + itemKeys = cache.linkedHashMap.keySet().toArray(new String[0]); + // save should move user3 to bottom of the list and push up others. + assertEquals("user1", itemKeys[0]); + assertEquals("user2", itemKeys[1]); + assertEquals("user3", itemKeys[2]); + } + + @Test + public void whenCacheIsDisabled() { + DefaultLRUCache> cache = new DefaultLRUCache<>(0,Cache.DEFAULT_TIMEOUT_SECONDS); + + cache.save("user1", Arrays.asList("segment1", "segment2")); + cache.save("user2", Arrays.asList("segment3", "segment4")); + cache.save("user3", Arrays.asList("segment5", "segment6")); + + assertNull(cache.lookup("user1")); + assertNull(cache.lookup("user2")); + assertNull(cache.lookup("user3")); + } + + @Test + public void whenItemsExpire() throws InterruptedException { + DefaultLRUCache> cache = new DefaultLRUCache<>(Cache.DEFAULT_MAX_SIZE, 1); + cache.save("user1", Arrays.asList("segment1", "segment2")); + assertEquals(Arrays.asList("segment1", "segment2"), cache.lookup("user1")); + assertEquals(1, cache.linkedHashMap.size()); + Thread.sleep(1000); + assertNull(cache.lookup("user1")); + assertEquals(0, cache.linkedHashMap.size()); + } + + @Test + public void whenCacheReachesMaxSize() { + DefaultLRUCache> cache = new DefaultLRUCache<>(2, Cache.DEFAULT_TIMEOUT_SECONDS); + + cache.save("user1", Arrays.asList("segment1", "segment2")); + cache.save("user2", Arrays.asList("segment3", "segment4")); + cache.save("user3", Arrays.asList("segment5", "segment6")); + + assertEquals(2, cache.linkedHashMap.size()); + + assertEquals(Arrays.asList("segment5", "segment6"), cache.lookup("user3")); + assertEquals(Arrays.asList("segment3", "segment4"), cache.lookup("user2")); + assertNull(cache.lookup("user1")); + } + + @Test + public void whenCacheIsReset() { + DefaultLRUCache> cache = new DefaultLRUCache<>(); + cache.save("user1", Arrays.asList("segment1", "segment2")); + cache.save("user2", Arrays.asList("segment3", "segment4")); + cache.save("user3", Arrays.asList("segment5", "segment6")); + + assertEquals(Arrays.asList("segment1", "segment2"), cache.lookup("user1")); + assertEquals(Arrays.asList("segment3", "segment4"), cache.lookup("user2")); + assertEquals(Arrays.asList("segment5", "segment6"), cache.lookup("user3")); + + assertEquals(3, cache.linkedHashMap.size()); + + cache.reset(); + + assertNull(cache.lookup("user1")); + assertNull(cache.lookup("user2")); + assertNull(cache.lookup("user3")); + + assertEquals(0, cache.linkedHashMap.size()); + } +}