diff --git a/src/main/java/com/thealgorithms/datastructures/caches/RRCache.java b/src/main/java/com/thealgorithms/datastructures/caches/RRCache.java new file mode 100644 index 000000000000..c90cb8e0c47c --- /dev/null +++ b/src/main/java/com/thealgorithms/datastructures/caches/RRCache.java @@ -0,0 +1,400 @@ +package com.thealgorithms.datastructures.caches; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.BiConsumer; + +/** + * A thread-safe generic cache implementation using the Random Replacement (RR) eviction policy. + *

+ * The cache holds a fixed number of entries, defined by its capacity. When the cache is full and a + * new entry is added, one of the existing entries is selected at random and evicted to make space. + *

+ * Optionally, entries can have a time-to-live (TTL) in milliseconds. If a TTL is set, entries will + * automatically expire and be removed upon access or insertion attempts. + *

+ * Features: + *

+ * + * @param the type of keys maintained by this cache + * @param the type of mapped values + * + * See Random Replacement + * @author Kevin Babu (GitHub) + */ +public final class RRCache { + + private final int capacity; + private final long defaultTTL; + private final Map> cache; + private final List keys; + private final Random random; + private final Lock lock; + + private long hits = 0; + private long misses = 0; + + private final BiConsumer evictionListener; + + /** + * Internal structure to store value + expiry timestamp. + * + * @param the type of the value being cached + */ + private static class CacheEntry { + V value; + long expiryTime; + + /** + * Constructs a new {@code CacheEntry} with the specified value and time-to-live (TTL). + * + * @param value the value to cache + * @param ttlMillis the time-to-live in milliseconds + */ + CacheEntry(V value, long ttlMillis) { + this.value = value; + this.expiryTime = System.currentTimeMillis() + ttlMillis; + } + + /** + * Checks if the cache entry has expired. + * + * @return {@code true} if the current time is past the expiration time; {@code false} otherwise + */ + boolean isExpired() { + return System.currentTimeMillis() > expiryTime; + } + } + + /** + * Constructs a new {@code RRCache} instance using the provided {@link Builder}. + * + *

This constructor initializes the cache with the specified capacity and default TTL, + * sets up internal data structures (a {@code HashMap} for cache entries and an {@code ArrayList} + * for key tracking), and configures eviction and randomization behavior. + * + * @param builder the {@code Builder} object containing configuration parameters + */ + private RRCache(Builder builder) { + this.capacity = builder.capacity; + this.defaultTTL = builder.defaultTTL; + this.cache = new HashMap<>(builder.capacity); + this.keys = new ArrayList<>(builder.capacity); + this.random = builder.random != null ? builder.random : new Random(); + this.lock = new ReentrantLock(); + this.evictionListener = builder.evictionListener; + } + + /** + * Retrieves the value associated with the specified key from the cache. + * + *

If the key is not present or the corresponding entry has expired, this method + * returns {@code null}. If an expired entry is found, it will be removed and the + * eviction listener (if any) will be notified. Cache hit-and-miss statistics are + * also updated accordingly. + * + * @param key the key whose associated value is to be returned; must not be {@code null} + * @return the cached value associated with the key, or {@code null} if not present or expired + * @throws IllegalArgumentException if {@code key} is {@code null} + */ + public V get(K key) { + if (key == null) { + throw new IllegalArgumentException("Key must not be null"); + } + + lock.lock(); + try { + CacheEntry entry = cache.get(key); + if (entry == null || entry.isExpired()) { + if (entry != null) { + removeKey(key); + notifyEviction(key, entry.value); + } + misses++; + return null; + } + hits++; + return entry.value; + } finally { + lock.unlock(); + } + } + + /** + * Adds a key-value pair to the cache using the default time-to-live (TTL). + * + *

The key may overwrite an existing entry. The actual insertion is delegated + * to the overloaded {@link #put(K, V, long)} method. + * + * @param key the key to cache the value under + * @param value the value to be cached + */ + public void put(K key, V value) { + put(key, value, defaultTTL); + } + + /** + * Adds a key-value pair to the cache with a specified time-to-live (TTL). + * + *

If the key already exists, its value is updated and its TTL is reset. If the key + * does not exist and the cache is full, a random entry is evicted to make space. + * Expired entries are also cleaned up prior to any eviction. The eviction listener + * is notified when an entry gets evicted. + * + * @param key the key to associate with the cached value; must not be {@code null} + * @param value the value to be cached; must not be {@code null} + * @param ttlMillis the time-to-live for this entry in milliseconds; must be >= 0 + * @throws IllegalArgumentException if {@code key} or {@code value} is {@code null}, or if {@code ttlMillis} is negative + */ + public void put(K key, V value, long ttlMillis) { + if (key == null || value == null) { + throw new IllegalArgumentException("Key and value must not be null"); + } + if (ttlMillis < 0) { + throw new IllegalArgumentException("TTL must be >= 0"); + } + + lock.lock(); + try { + if (cache.containsKey(key)) { + cache.put(key, new CacheEntry<>(value, ttlMillis)); + return; + } + + evictExpired(); + + if (cache.size() >= capacity) { + int idx = random.nextInt(keys.size()); + K evictKey = keys.remove(idx); + CacheEntry evictVal = cache.remove(evictKey); + notifyEviction(evictKey, evictVal.value); + } + + cache.put(key, new CacheEntry<>(value, ttlMillis)); + keys.add(key); + } finally { + lock.unlock(); + } + } + + /** + * Removes all expired entries from the cache. + * + *

This method iterates through the list of cached keys and checks each associated + * entry for expiration. Expired entries are removed from both the key tracking list + * and the cache map. For each eviction, the eviction listener is notified. + */ + private void evictExpired() { + Iterator it = keys.iterator(); + while (it.hasNext()) { + K k = it.next(); + CacheEntry entry = cache.get(k); + if (entry != null && entry.isExpired()) { + it.remove(); + cache.remove(k); + notifyEviction(k, entry.value); + } + } + } + + /** + * Removes the specified key and its associated entry from the cache. + * + *

This method deletes the key from both the cache map and the key tracking list. + * + * @param key the key to remove from the cache + */ + private void removeKey(K key) { + cache.remove(key); + keys.remove(key); + } + + /** + * Notifies the eviction listener, if one is registered, that a key-value pair has been evicted. + * + *

If the {@code evictionListener} is not {@code null}, it is invoked with the provided key + * and value. Any exceptions thrown by the listener are caught and logged to standard error, + * preventing them from disrupting cache operations. + * + * @param key the key that was evicted + * @param value the value that was associated with the evicted key + */ + private void notifyEviction(K key, V value) { + if (evictionListener != null) { + try { + evictionListener.accept(key, value); + } catch (Exception e) { + System.err.println("Eviction listener failed: " + e.getMessage()); + } + } + } + + /** + * Returns the number of successful cache lookups (hits). + * + * @return the number of cache hits + */ + public long getHits() { + lock.lock(); + try { + return hits; + } finally { + lock.unlock(); + } + } + + /** + * Returns the number of failed cache lookups (misses), including expired entries. + * + * @return the number of cache misses + */ + public long getMisses() { + lock.lock(); + try { + return misses; + } finally { + lock.unlock(); + } + } + + /** + * Returns the current number of entries in the cache, excluding expired ones. + * + * @return the current cache size + */ + public int size() { + lock.lock(); + try { + int count = 0; + for (Map.Entry> entry : cache.entrySet()) { + if (!entry.getValue().isExpired()) { + ++count; + } + } + return count; + } finally { + lock.unlock(); + } + } + + /** + * Returns a string representation of the cache, including metadata and current non-expired entries. + * + *

The returned string includes the cache's capacity, current size (excluding expired entries), + * hit-and-miss counts, and a map of all non-expired key-value pairs. This method acquires a lock + * to ensure thread-safe access. + * + * @return a string summarizing the state of the cache + */ + @Override + public String toString() { + lock.lock(); + try { + Map visible = new HashMap<>(); + for (Map.Entry> entry : cache.entrySet()) { + if (!entry.getValue().isExpired()) { + visible.put(entry.getKey(), entry.getValue().value); + } + } + return String.format("Cache(capacity=%d, size=%d, hits=%d, misses=%d, entries=%s)", capacity, visible.size(), hits, misses, visible); + } finally { + lock.unlock(); + } + } + + /** + * A builder for creating instances of {@link RRCache} with custom configuration. + * + *

This static inner class allows you to configure parameters such as cache capacity, + * default TTL (time-to-live), random eviction behavior, and an optional eviction listener. + * Once configured, use {@link #build()} to create the {@code RRCache} instance. + * + * @param the type of keys maintained by the cache + * @param the type of values stored in the cache + */ + public static class Builder { + private final int capacity; + private long defaultTTL = 0; + private Random random; + private BiConsumer evictionListener; + + /** + * Creates a new {@code Builder} with the specified cache capacity. + * + * @param capacity the maximum number of entries the cache can hold; must be > 0 + * @throws IllegalArgumentException if {@code capacity} is less than or equal to 0 + */ + public Builder(int capacity) { + if (capacity <= 0) { + throw new IllegalArgumentException("Capacity must be > 0"); + } + this.capacity = capacity; + } + + /** + * Sets the default time-to-live (TTL) in milliseconds for cache entries. + * + * @param ttlMillis the TTL duration in milliseconds; must be >= 0 + * @return this builder instance for chaining + * @throws IllegalArgumentException if {@code ttlMillis} is negative + */ + public Builder defaultTTL(long ttlMillis) { + if (ttlMillis < 0) { + throw new IllegalArgumentException("Default TTL must be >= 0"); + } + this.defaultTTL = ttlMillis; + return this; + } + + /** + * Sets the {@link Random} instance to be used for random eviction selection. + * + * @param r a non-null {@code Random} instance + * @return this builder instance for chaining + * @throws IllegalArgumentException if {@code r} is {@code null} + */ + public Builder random(Random r) { + if (r == null) { + throw new IllegalArgumentException("Random must not be null"); + } + this.random = r; + return this; + } + + /** + * Sets an eviction listener to be notified when entries are evicted from the cache. + * + * @param listener a {@link BiConsumer} that accepts evicted keys and values; must not be {@code null} + * @return this builder instance for chaining + * @throws IllegalArgumentException if {@code listener} is {@code null} + */ + public Builder evictionListener(BiConsumer listener) { + if (listener == null) { + throw new IllegalArgumentException("Listener must not be null"); + } + this.evictionListener = listener; + return this; + } + + /** + * Builds and returns a new {@link RRCache} instance with the configured parameters. + * + * @return a fully configured {@code RRCache} instance + */ + public RRCache build() { + return new RRCache<>(this); + } + } +} diff --git a/src/test/java/com/thealgorithms/datastructures/caches/RRCacheTest.java b/src/test/java/com/thealgorithms/datastructures/caches/RRCacheTest.java new file mode 100644 index 000000000000..eaefee3174dd --- /dev/null +++ b/src/test/java/com/thealgorithms/datastructures/caches/RRCacheTest.java @@ -0,0 +1,158 @@ +package com.thealgorithms.datastructures.caches; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Random; +import java.util.Set; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; + +class RRCacheTest { + + private RRCache cache; + private Set evictedKeys; + private List evictedValues; + + @BeforeEach + void setUp() { + evictedKeys = new HashSet<>(); + evictedValues = new ArrayList<>(); + + cache = new RRCache.Builder(3) + .defaultTTL(1000) + .random(new Random(0)) + .evictionListener((k, v) -> { + evictedKeys.add(k); + evictedValues.add(v); + }) + .build(); + } + + @Test + void testPutAndGet() { + cache.put("a", "apple"); + Assertions.assertEquals("apple", cache.get("a")); + } + + @Test + void testOverwriteValue() { + cache.put("a", "apple"); + cache.put("a", "avocado"); + Assertions.assertEquals("avocado", cache.get("a")); + } + + @Test + void testExpiration() throws InterruptedException { + cache.put("temp", "value", 100); // short TTL + Thread.sleep(200); + Assertions.assertNull(cache.get("temp")); + Assertions.assertTrue(evictedKeys.contains("temp")); + } + + @Test + void testEvictionOnCapacity() { + cache.put("a", "alpha"); + cache.put("b", "bravo"); + cache.put("c", "charlie"); + cache.put("d", "delta"); // triggers eviction + + int size = cache.size(); + Assertions.assertEquals(3, size); + Assertions.assertEquals(1, evictedKeys.size()); + Assertions.assertEquals(1, evictedValues.size()); + } + + @Test + void testEvictionListener() { + cache.put("x", "one"); + cache.put("y", "two"); + cache.put("z", "three"); + cache.put("w", "four"); // one of x, y, z will be evicted + + Assertions.assertFalse(evictedKeys.isEmpty()); + Assertions.assertFalse(evictedValues.isEmpty()); + } + + @Test + void testHitsAndMisses() { + cache.put("a", "apple"); + Assertions.assertEquals("apple", cache.get("a")); + Assertions.assertNull(cache.get("b")); + Assertions.assertEquals(1, cache.getHits()); + Assertions.assertEquals(1, cache.getMisses()); + } + + @Test + void testSizeExcludesExpired() throws InterruptedException { + cache.put("a", "a", 100); + cache.put("b", "b", 100); + cache.put("c", "c", 100); + Thread.sleep(150); + Assertions.assertEquals(0, cache.size()); + } + + @Test + void testToStringDoesNotExposeExpired() throws InterruptedException { + cache.put("live", "alive"); + cache.put("dead", "gone", 100); + Thread.sleep(150); + String result = cache.toString(); + Assertions.assertTrue(result.contains("live")); + Assertions.assertFalse(result.contains("dead")); + } + + @Test + void testNullKeyGetThrows() { + Assertions.assertThrows(IllegalArgumentException.class, () -> cache.get(null)); + } + + @Test + void testPutNullKeyThrows() { + Assertions.assertThrows(IllegalArgumentException.class, () -> cache.put(null, "v")); + } + + @Test + void testPutNullValueThrows() { + Assertions.assertThrows(IllegalArgumentException.class, () -> cache.put("k", null)); + } + + @Test + void testPutNegativeTTLThrows() { + Assertions.assertThrows(IllegalArgumentException.class, () -> cache.put("k", "v", -1)); + } + + @Test + void testBuilderNegativeCapacityThrows() { + Assertions.assertThrows(IllegalArgumentException.class, () -> new RRCache.Builder<>(0)); + } + + @Test + void testBuilderNullRandomThrows() { + RRCache.Builder builder = new RRCache.Builder<>(1); + Assertions.assertThrows(IllegalArgumentException.class, () -> builder.random(null)); + } + + @Test + void testBuilderNullEvictionListenerThrows() { + RRCache.Builder builder = new RRCache.Builder<>(1); + Assertions.assertThrows(IllegalArgumentException.class, () -> builder.evictionListener(null)); + } + + @Test + void testEvictionListenerExceptionDoesNotCrash() { + RRCache listenerCache = new RRCache.Builder(1).evictionListener((k, v) -> { throw new RuntimeException("Exception"); }).build(); + + listenerCache.put("a", "a"); + listenerCache.put("b", "b"); // causes eviction but should not crash + Assertions.assertDoesNotThrow(() -> listenerCache.get("a")); + } + + @Test + void testTtlZeroThrowsIllegalArgumentException() { + Executable exec = () -> new RRCache.Builder(3).defaultTTL(-1).build(); + Assertions.assertThrows(IllegalArgumentException.class, exec); + } +}