From 884fa295a52f2838abd2c880ffb94b115e301d0c Mon Sep 17 00:00:00 2001 From: KevinMwita7 Date: Fri, 20 Jun 2025 22:05:41 +0300 Subject: [PATCH 01/10] Added Random Replacement cache --- .../datastructures/caches/RRCache.java | 392 ++++++++++++++++++ .../datastructures/caches/RRCacheTest.java | 169 ++++++++ 2 files changed, 561 insertions(+) create mode 100644 src/main/java/com/thealgorithms/datastructures/caches/RRCache.java create mode 100644 src/test/java/com/thealgorithms/datastructures/caches/RRCacheTest.java 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..336379bf2188 --- /dev/null +++ b/src/main/java/com/thealgorithms/datastructures/caches/RRCache.java @@ -0,0 +1,392 @@ +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 + * + * @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.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..8541a41050dc --- /dev/null +++ b/src/test/java/com/thealgorithms/datastructures/caches/RRCacheTest.java @@ -0,0 +1,169 @@ +package com.thealgorithms.datastructures.caches; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RRCacheTest { + + private RRCache cache; + private List evictedKeys; + private List evictedValues; + + @BeforeEach + void setUp() { + evictedKeys = new ArrayList<>(); + 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"); + assertEquals("apple", cache.get("a")); + } + + @Test + void testOverwriteValue() { + cache.put("a", "apple"); + cache.put("a", "avocado"); + assertEquals("avocado", cache.get("a")); + } + + @Test + void testExpiration() throws InterruptedException { + cache.put("temp", "value", 100); // short TTL + Thread.sleep(200); + assertNull(cache.get("temp")); + 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(); + assertEquals(3, size); + assertEquals(1, evictedKeys.size()); + 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 + + assertFalse(evictedKeys.isEmpty()); + assertFalse(evictedValues.isEmpty()); + } + + @Test + void testHitsAndMisses() { + cache.put("a", "apple"); + assertEquals("apple", cache.get("a")); + assertNull(cache.get("b")); + assertEquals(1, cache.getHits()); + 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); + 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(); + assertTrue(result.contains("live")); + assertFalse(result.contains("dead")); + } + + @Test + void testNullKeyGetThrows() { + assertThrows(IllegalArgumentException.class, () -> cache.get(null)); + } + + @Test + void testPutNullKeyThrows() { + assertThrows(IllegalArgumentException.class, () -> cache.put(null, "v")); + } + + @Test + void testPutNullValueThrows() { + assertThrows(IllegalArgumentException.class, () -> cache.put("k", null)); + } + + @Test + void testPutNegativeTTLThrows() { + assertThrows(IllegalArgumentException.class, () -> cache.put("k", "v", -1)); + } + + @Test + void testBuilderNegativeCapacityThrows() { + assertThrows(IllegalArgumentException.class, () -> new RRCache.Builder<>(0)); + } + + @Test + void testBuilderNullRandomThrows() { + RRCache.Builder builder = new RRCache.Builder<>(1); + assertThrows(IllegalArgumentException.class, () -> builder.random(null)); + } + + @Test + void testBuilderNullEvictionListenerThrows() { + RRCache.Builder builder = new RRCache.Builder<>(1); + 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 + assertDoesNotThrow(() -> listenerCache.get("a")); + } + + @Test + void testTtlZeroThrowsIllegalArgumentException() { + Executable exec = () -> new RRCache.Builder(3) + .defaultTTL(-1) + .build(); + assertThrows(IllegalArgumentException.class, exec); + } +} From 9406eb90c8bd869d80b39a3b993752edff900532 Mon Sep 17 00:00:00 2001 From: KevinMwita7 Date: Fri, 20 Jun 2025 22:26:14 +0300 Subject: [PATCH 02/10] Added Wikipedia link --- .../java/com/thealgorithms/datastructures/caches/RRCache.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/thealgorithms/datastructures/caches/RRCache.java b/src/main/java/com/thealgorithms/datastructures/caches/RRCache.java index 336379bf2188..41d405e99079 100644 --- a/src/main/java/com/thealgorithms/datastructures/caches/RRCache.java +++ b/src/main/java/com/thealgorithms/datastructures/caches/RRCache.java @@ -31,6 +31,7 @@ * @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 { From 6ebce2eeafd87f62fad14a73503c5ffceb866f30 Mon Sep 17 00:00:00 2001 From: KevinMwita7 Date: Fri, 20 Jun 2025 23:45:45 +0300 Subject: [PATCH 03/10] Fixes --- .../datastructures/caches/RRCache.java | 17 ++++--- .../datastructures/caches/RRCacheTest.java | 45 +++++++++---------- 2 files changed, 32 insertions(+), 30 deletions(-) diff --git a/src/main/java/com/thealgorithms/datastructures/caches/RRCache.java b/src/main/java/com/thealgorithms/datastructures/caches/RRCache.java index 41d405e99079..c90cb8e0c47c 100644 --- a/src/main/java/com/thealgorithms/datastructures/caches/RRCache.java +++ b/src/main/java/com/thealgorithms/datastructures/caches/RRCache.java @@ -201,7 +201,7 @@ private void evictExpired() { while (it.hasNext()) { K k = it.next(); CacheEntry entry = cache.get(k); - if (entry.isExpired()) { + if (entry != null && entry.isExpired()) { it.remove(); cache.remove(k); notifyEviction(k, entry.value); @@ -248,7 +248,11 @@ private void notifyEviction(K key, V value) { */ public long getHits() { lock.lock(); - try { return hits; } finally { lock.unlock(); } + try { + return hits; + } finally { + lock.unlock(); + } } /** @@ -258,7 +262,11 @@ public long getHits() { */ public long getMisses() { lock.lock(); - try { return misses; } finally { lock.unlock(); } + try { + return misses; + } finally { + lock.unlock(); + } } /** @@ -300,8 +308,7 @@ public String toString() { 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); + return String.format("Cache(capacity=%d, size=%d, hits=%d, misses=%d, entries=%s)", capacity, visible.size(), hits, misses, visible); } finally { lock.unlock(); } diff --git a/src/test/java/com/thealgorithms/datastructures/caches/RRCacheTest.java b/src/test/java/com/thealgorithms/datastructures/caches/RRCacheTest.java index 8541a41050dc..5866a1e6de7b 100644 --- a/src/test/java/com/thealgorithms/datastructures/caches/RRCacheTest.java +++ b/src/test/java/com/thealgorithms/datastructures/caches/RRCacheTest.java @@ -1,13 +1,5 @@ package com.thealgorithms.datastructures.caches; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.function.Executable; - -import java.util.ArrayList; -import java.util.List; -import java.util.Random; - import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -15,25 +7,34 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +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.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; + class RRCacheTest { private RRCache cache; - private List evictedKeys; + private Set evictedKeys; private List evictedValues; @BeforeEach void setUp() { - evictedKeys = new ArrayList<>(); + 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(); + .defaultTTL(1000) + .random(new Random(0)) + .evictionListener((k, v) -> { + evictedKeys.add(k); + evictedValues.add(v); + }) + .build(); } @Test @@ -148,11 +149,7 @@ void testBuilderNullEvictionListenerThrows() { @Test void testEvictionListenerExceptionDoesNotCrash() { - RRCache listenerCache = new RRCache.Builder(1) - .evictionListener((k, v) -> { - throw new RuntimeException("Exception"); - }) - .build(); + 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 @@ -161,9 +158,7 @@ void testEvictionListenerExceptionDoesNotCrash() { @Test void testTtlZeroThrowsIllegalArgumentException() { - Executable exec = () -> new RRCache.Builder(3) - .defaultTTL(-1) - .build(); + Executable exec = () -> new RRCache.Builder(3).defaultTTL(-1).build(); assertThrows(IllegalArgumentException.class, exec); } } From 28c505a127005ef129968a8e0e08197ffd6429b0 Mon Sep 17 00:00:00 2001 From: KevinMwita7 Date: Fri, 20 Jun 2025 22:05:41 +0300 Subject: [PATCH 04/10] Added Random Replacement cache --- .../datastructures/caches/RRCache.java | 392 ++++++++++++++++++ .../datastructures/caches/RRCacheTest.java | 169 ++++++++ 2 files changed, 561 insertions(+) create mode 100644 src/main/java/com/thealgorithms/datastructures/caches/RRCache.java create mode 100644 src/test/java/com/thealgorithms/datastructures/caches/RRCacheTest.java 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..336379bf2188 --- /dev/null +++ b/src/main/java/com/thealgorithms/datastructures/caches/RRCache.java @@ -0,0 +1,392 @@ +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: + *

    + *
  • Random eviction when capacity is exceeded
  • + *
  • Optional TTL (time-to-live in milliseconds) per entry or default TTL for all entries
  • + *
  • Thread-safe access using locking
  • + *
  • Hit and miss counters for cache statistics
  • + *
  • Eviction listener callback support
  • + *
+ * + * @param the type of keys maintained by this cache + * @param the type of mapped values + * + * @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.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..8541a41050dc --- /dev/null +++ b/src/test/java/com/thealgorithms/datastructures/caches/RRCacheTest.java @@ -0,0 +1,169 @@ +package com.thealgorithms.datastructures.caches; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RRCacheTest { + + private RRCache cache; + private List evictedKeys; + private List evictedValues; + + @BeforeEach + void setUp() { + evictedKeys = new ArrayList<>(); + 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"); + assertEquals("apple", cache.get("a")); + } + + @Test + void testOverwriteValue() { + cache.put("a", "apple"); + cache.put("a", "avocado"); + assertEquals("avocado", cache.get("a")); + } + + @Test + void testExpiration() throws InterruptedException { + cache.put("temp", "value", 100); // short TTL + Thread.sleep(200); + assertNull(cache.get("temp")); + 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(); + assertEquals(3, size); + assertEquals(1, evictedKeys.size()); + 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 + + assertFalse(evictedKeys.isEmpty()); + assertFalse(evictedValues.isEmpty()); + } + + @Test + void testHitsAndMisses() { + cache.put("a", "apple"); + assertEquals("apple", cache.get("a")); + assertNull(cache.get("b")); + assertEquals(1, cache.getHits()); + 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); + 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(); + assertTrue(result.contains("live")); + assertFalse(result.contains("dead")); + } + + @Test + void testNullKeyGetThrows() { + assertThrows(IllegalArgumentException.class, () -> cache.get(null)); + } + + @Test + void testPutNullKeyThrows() { + assertThrows(IllegalArgumentException.class, () -> cache.put(null, "v")); + } + + @Test + void testPutNullValueThrows() { + assertThrows(IllegalArgumentException.class, () -> cache.put("k", null)); + } + + @Test + void testPutNegativeTTLThrows() { + assertThrows(IllegalArgumentException.class, () -> cache.put("k", "v", -1)); + } + + @Test + void testBuilderNegativeCapacityThrows() { + assertThrows(IllegalArgumentException.class, () -> new RRCache.Builder<>(0)); + } + + @Test + void testBuilderNullRandomThrows() { + RRCache.Builder builder = new RRCache.Builder<>(1); + assertThrows(IllegalArgumentException.class, () -> builder.random(null)); + } + + @Test + void testBuilderNullEvictionListenerThrows() { + RRCache.Builder builder = new RRCache.Builder<>(1); + 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 + assertDoesNotThrow(() -> listenerCache.get("a")); + } + + @Test + void testTtlZeroThrowsIllegalArgumentException() { + Executable exec = () -> new RRCache.Builder(3) + .defaultTTL(-1) + .build(); + assertThrows(IllegalArgumentException.class, exec); + } +} From 700f8f729329f0fc8bdd48beec9ae8b0e66526af Mon Sep 17 00:00:00 2001 From: KevinMwita7 Date: Fri, 20 Jun 2025 22:26:14 +0300 Subject: [PATCH 05/10] Added Wikipedia link --- .../java/com/thealgorithms/datastructures/caches/RRCache.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/thealgorithms/datastructures/caches/RRCache.java b/src/main/java/com/thealgorithms/datastructures/caches/RRCache.java index 336379bf2188..41d405e99079 100644 --- a/src/main/java/com/thealgorithms/datastructures/caches/RRCache.java +++ b/src/main/java/com/thealgorithms/datastructures/caches/RRCache.java @@ -31,6 +31,7 @@ * @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 { From 25427f31988461dd99bbae6fbcd428cd9091dc2c Mon Sep 17 00:00:00 2001 From: KevinMwita7 Date: Fri, 20 Jun 2025 23:45:45 +0300 Subject: [PATCH 06/10] Fixes --- .../datastructures/caches/RRCache.java | 17 ++++--- .../datastructures/caches/RRCacheTest.java | 45 +++++++++---------- 2 files changed, 32 insertions(+), 30 deletions(-) diff --git a/src/main/java/com/thealgorithms/datastructures/caches/RRCache.java b/src/main/java/com/thealgorithms/datastructures/caches/RRCache.java index 41d405e99079..c90cb8e0c47c 100644 --- a/src/main/java/com/thealgorithms/datastructures/caches/RRCache.java +++ b/src/main/java/com/thealgorithms/datastructures/caches/RRCache.java @@ -201,7 +201,7 @@ private void evictExpired() { while (it.hasNext()) { K k = it.next(); CacheEntry entry = cache.get(k); - if (entry.isExpired()) { + if (entry != null && entry.isExpired()) { it.remove(); cache.remove(k); notifyEviction(k, entry.value); @@ -248,7 +248,11 @@ private void notifyEviction(K key, V value) { */ public long getHits() { lock.lock(); - try { return hits; } finally { lock.unlock(); } + try { + return hits; + } finally { + lock.unlock(); + } } /** @@ -258,7 +262,11 @@ public long getHits() { */ public long getMisses() { lock.lock(); - try { return misses; } finally { lock.unlock(); } + try { + return misses; + } finally { + lock.unlock(); + } } /** @@ -300,8 +308,7 @@ public String toString() { 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); + return String.format("Cache(capacity=%d, size=%d, hits=%d, misses=%d, entries=%s)", capacity, visible.size(), hits, misses, visible); } finally { lock.unlock(); } diff --git a/src/test/java/com/thealgorithms/datastructures/caches/RRCacheTest.java b/src/test/java/com/thealgorithms/datastructures/caches/RRCacheTest.java index 8541a41050dc..5866a1e6de7b 100644 --- a/src/test/java/com/thealgorithms/datastructures/caches/RRCacheTest.java +++ b/src/test/java/com/thealgorithms/datastructures/caches/RRCacheTest.java @@ -1,13 +1,5 @@ package com.thealgorithms.datastructures.caches; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.function.Executable; - -import java.util.ArrayList; -import java.util.List; -import java.util.Random; - import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -15,25 +7,34 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +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.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; + class RRCacheTest { private RRCache cache; - private List evictedKeys; + private Set evictedKeys; private List evictedValues; @BeforeEach void setUp() { - evictedKeys = new ArrayList<>(); + 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(); + .defaultTTL(1000) + .random(new Random(0)) + .evictionListener((k, v) -> { + evictedKeys.add(k); + evictedValues.add(v); + }) + .build(); } @Test @@ -148,11 +149,7 @@ void testBuilderNullEvictionListenerThrows() { @Test void testEvictionListenerExceptionDoesNotCrash() { - RRCache listenerCache = new RRCache.Builder(1) - .evictionListener((k, v) -> { - throw new RuntimeException("Exception"); - }) - .build(); + 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 @@ -161,9 +158,7 @@ void testEvictionListenerExceptionDoesNotCrash() { @Test void testTtlZeroThrowsIllegalArgumentException() { - Executable exec = () -> new RRCache.Builder(3) - .defaultTTL(-1) - .build(); + Executable exec = () -> new RRCache.Builder(3).defaultTTL(-1).build(); assertThrows(IllegalArgumentException.class, exec); } } From bcc060a605b5d36b63c4501d178e03c1f090c03e Mon Sep 17 00:00:00 2001 From: KevinMwita7 Date: Sat, 21 Jun 2025 00:26:53 +0300 Subject: [PATCH 07/10] Fixes --- .../datastructures/caches/RRCacheTest.java | 58 +++++++++---------- 1 file changed, 26 insertions(+), 32 deletions(-) diff --git a/src/test/java/com/thealgorithms/datastructures/caches/RRCacheTest.java b/src/test/java/com/thealgorithms/datastructures/caches/RRCacheTest.java index 5866a1e6de7b..eaefee3174dd 100644 --- a/src/test/java/com/thealgorithms/datastructures/caches/RRCacheTest.java +++ b/src/test/java/com/thealgorithms/datastructures/caches/RRCacheTest.java @@ -1,17 +1,11 @@ package com.thealgorithms.datastructures.caches; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - 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; @@ -40,22 +34,22 @@ void setUp() { @Test void testPutAndGet() { cache.put("a", "apple"); - assertEquals("apple", cache.get("a")); + Assertions.assertEquals("apple", cache.get("a")); } @Test void testOverwriteValue() { cache.put("a", "apple"); cache.put("a", "avocado"); - assertEquals("avocado", cache.get("a")); + Assertions.assertEquals("avocado", cache.get("a")); } @Test void testExpiration() throws InterruptedException { cache.put("temp", "value", 100); // short TTL Thread.sleep(200); - assertNull(cache.get("temp")); - assertTrue(evictedKeys.contains("temp")); + Assertions.assertNull(cache.get("temp")); + Assertions.assertTrue(evictedKeys.contains("temp")); } @Test @@ -66,9 +60,9 @@ void testEvictionOnCapacity() { cache.put("d", "delta"); // triggers eviction int size = cache.size(); - assertEquals(3, size); - assertEquals(1, evictedKeys.size()); - assertEquals(1, evictedValues.size()); + Assertions.assertEquals(3, size); + Assertions.assertEquals(1, evictedKeys.size()); + Assertions.assertEquals(1, evictedValues.size()); } @Test @@ -78,17 +72,17 @@ void testEvictionListener() { cache.put("z", "three"); cache.put("w", "four"); // one of x, y, z will be evicted - assertFalse(evictedKeys.isEmpty()); - assertFalse(evictedValues.isEmpty()); + Assertions.assertFalse(evictedKeys.isEmpty()); + Assertions.assertFalse(evictedValues.isEmpty()); } @Test void testHitsAndMisses() { cache.put("a", "apple"); - assertEquals("apple", cache.get("a")); - assertNull(cache.get("b")); - assertEquals(1, cache.getHits()); - assertEquals(1, cache.getMisses()); + Assertions.assertEquals("apple", cache.get("a")); + Assertions.assertNull(cache.get("b")); + Assertions.assertEquals(1, cache.getHits()); + Assertions.assertEquals(1, cache.getMisses()); } @Test @@ -97,7 +91,7 @@ void testSizeExcludesExpired() throws InterruptedException { cache.put("b", "b", 100); cache.put("c", "c", 100); Thread.sleep(150); - assertEquals(0, cache.size()); + Assertions.assertEquals(0, cache.size()); } @Test @@ -106,45 +100,45 @@ void testToStringDoesNotExposeExpired() throws InterruptedException { cache.put("dead", "gone", 100); Thread.sleep(150); String result = cache.toString(); - assertTrue(result.contains("live")); - assertFalse(result.contains("dead")); + Assertions.assertTrue(result.contains("live")); + Assertions.assertFalse(result.contains("dead")); } @Test void testNullKeyGetThrows() { - assertThrows(IllegalArgumentException.class, () -> cache.get(null)); + Assertions.assertThrows(IllegalArgumentException.class, () -> cache.get(null)); } @Test void testPutNullKeyThrows() { - assertThrows(IllegalArgumentException.class, () -> cache.put(null, "v")); + Assertions.assertThrows(IllegalArgumentException.class, () -> cache.put(null, "v")); } @Test void testPutNullValueThrows() { - assertThrows(IllegalArgumentException.class, () -> cache.put("k", null)); + Assertions.assertThrows(IllegalArgumentException.class, () -> cache.put("k", null)); } @Test void testPutNegativeTTLThrows() { - assertThrows(IllegalArgumentException.class, () -> cache.put("k", "v", -1)); + Assertions.assertThrows(IllegalArgumentException.class, () -> cache.put("k", "v", -1)); } @Test void testBuilderNegativeCapacityThrows() { - assertThrows(IllegalArgumentException.class, () -> new RRCache.Builder<>(0)); + Assertions.assertThrows(IllegalArgumentException.class, () -> new RRCache.Builder<>(0)); } @Test void testBuilderNullRandomThrows() { RRCache.Builder builder = new RRCache.Builder<>(1); - assertThrows(IllegalArgumentException.class, () -> builder.random(null)); + Assertions.assertThrows(IllegalArgumentException.class, () -> builder.random(null)); } @Test void testBuilderNullEvictionListenerThrows() { RRCache.Builder builder = new RRCache.Builder<>(1); - assertThrows(IllegalArgumentException.class, () -> builder.evictionListener(null)); + Assertions.assertThrows(IllegalArgumentException.class, () -> builder.evictionListener(null)); } @Test @@ -153,12 +147,12 @@ void testEvictionListenerExceptionDoesNotCrash() { listenerCache.put("a", "a"); listenerCache.put("b", "b"); // causes eviction but should not crash - assertDoesNotThrow(() -> listenerCache.get("a")); + Assertions.assertDoesNotThrow(() -> listenerCache.get("a")); } @Test void testTtlZeroThrowsIllegalArgumentException() { Executable exec = () -> new RRCache.Builder(3).defaultTTL(-1).build(); - assertThrows(IllegalArgumentException.class, exec); + Assertions.assertThrows(IllegalArgumentException.class, exec); } } From 3b18922108312a7aea886bf2f318a8156205bea5 Mon Sep 17 00:00:00 2001 From: KevinMwita7 Date: Wed, 25 Jun 2025 18:16:41 +0300 Subject: [PATCH 08/10] Add pluggable eviction strategy defaulting to periodic eviction --- .../datastructures/caches/RRCache.java | 112 ++++++++++++++++-- .../datastructures/caches/RRCacheTest.java | 53 +++++++++ 2 files changed, 157 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/thealgorithms/datastructures/caches/RRCache.java b/src/main/java/com/thealgorithms/datastructures/caches/RRCache.java index c90cb8e0c47c..2bf9ac57282c 100644 --- a/src/main/java/com/thealgorithms/datastructures/caches/RRCache.java +++ b/src/main/java/com/thealgorithms/datastructures/caches/RRCache.java @@ -30,7 +30,6 @@ * * @param the type of keys maintained by this cache * @param the type of mapped values - * * See Random Replacement * @author Kevin Babu (GitHub) */ @@ -45,8 +44,8 @@ public final class RRCache { private long hits = 0; private long misses = 0; - private final BiConsumer evictionListener; + private final EvictionStrategy evictionStrategy; /** * Internal structure to store value + expiry timestamp. @@ -95,6 +94,7 @@ private RRCache(Builder builder) { this.random = builder.random != null ? builder.random : new Random(); this.lock = new ReentrantLock(); this.evictionListener = builder.evictionListener; + this.evictionStrategy = builder.evictionStrategy; } /** @@ -116,6 +116,8 @@ public V get(K key) { lock.lock(); try { + evictionStrategy.onAccess(this); + CacheEntry entry = cache.get(key); if (entry == null || entry.isExpired()) { if (entry != null) { @@ -196,17 +198,22 @@ public void put(K key, V value, long ttlMillis) { * 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() { + private int evictExpired() { Iterator it = keys.iterator(); + int expiredCount = 0; + while (it.hasNext()) { K k = it.next(); CacheEntry entry = cache.get(k); if (entry != null && entry.isExpired()) { it.remove(); cache.remove(k); + ++expiredCount; notifyEviction(k, entry.value); } } + + return expiredCount; } /** @@ -277,6 +284,13 @@ public long getMisses() { public int size() { lock.lock(); try { + int cachedSize = cache.size(); + int evictedCount = evictionStrategy.onAccess(this); + if (evictedCount > 0) { + return cachedSize - evictedCount; + } + + // This runs if periodic eviction does not occur int count = 0; for (Map.Entry> entry : cache.entrySet()) { if (!entry.getValue().isExpired()) { @@ -315,11 +329,78 @@ public String toString() { } /** - * A builder for creating instances of {@link RRCache} with custom configuration. + * A strategy interface for controlling when expired entries are evicted from the cache. * - *

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. + *

Implementations decide whether and when to trigger {@link RRCache#evictExpired()} based + * on cache usage patterns. This allows for flexible eviction behaviour such as periodic cleanup, + * or no automatic cleanup. + * + * @param the type of keys maintained by the cache + * @param the type of cached values + */ + public interface EvictionStrategy { + /** + * Called on each cache access (e.g., {@link RRCache#get(Object)}) to optionally trigger eviction. + * + * @param cache the cache instance on which this strategy is applied + * @return the number of expired entries evicted during this access + */ + int onAccess(RRCache cache); + } + + /** + * An eviction strategy that performs eviction of expired entries on each call. + * + * @param the type of keys + * @param the type of values + */ + public static class NoEvictionStrategy implements EvictionStrategy { + @Override public int onAccess(RRCache cache) { + return cache.evictExpired(); + } + } + + /** + * An eviction strategy that triggers eviction every fixed number of accesses. + * + *

This deterministic strategy ensures cleanup occurs at predictable intervals, + * ideal for moderately active caches where memory usage is a concern. + * + * @param the type of keys + * @param the type of values + */ + public static class PeriodicEvictionStrategy implements EvictionStrategy { + private final int interval; + private int counter = 0; + + /** + * Constructs a periodic eviction strategy. + * + * @param interval the number of accesses between evictions; must be > 0 + * @throws IllegalArgumentException if {@code interval} is less than or equal to 0 + */ + public PeriodicEvictionStrategy(int interval) { + if (interval <= 0) { + throw new IllegalArgumentException("Interval must be > 0"); + } + this.interval = interval; + } + + @Override + public int onAccess(RRCache cache) { + if (++counter % interval == 0) { + return cache.evictExpired(); + } + + return 0; + } + } + + /** + * A builder for constructing an {@link RRCache} instance with customizable settings. + * + *

Allows configuring capacity, default TTL, random eviction behavior, eviction listener, + * and a pluggable eviction strategy. Call {@link #build()} to create the configured cache instance. * * @param the type of keys maintained by the cache * @param the type of values stored in the cache @@ -329,7 +410,7 @@ public static class Builder { private long defaultTTL = 0; private Random random; private BiConsumer evictionListener; - + private EvictionStrategy evictionStrategy = new RRCache.PeriodicEvictionStrategy<>(100); /** * Creates a new {@code Builder} with the specified cache capacity. * @@ -396,5 +477,20 @@ public Builder evictionListener(BiConsumer listener) { public RRCache build() { return new RRCache<>(this); } + + /** + * Sets the eviction strategy used to determine when to clean up expired entries. + * + * @param strategy an {@link EvictionStrategy} implementation; must not be {@code null} + * @return this builder instance + * @throws IllegalArgumentException if {@code strategy} is {@code null} + */ + public Builder evictionStrategy(EvictionStrategy strategy) { + if (strategy == null) { + throw new IllegalArgumentException("Eviction strategy must not be null"); + } + this.evictionStrategy = strategy; + return this; + } } } diff --git a/src/test/java/com/thealgorithms/datastructures/caches/RRCacheTest.java b/src/test/java/com/thealgorithms/datastructures/caches/RRCacheTest.java index eaefee3174dd..f2737ae8f645 100644 --- a/src/test/java/com/thealgorithms/datastructures/caches/RRCacheTest.java +++ b/src/test/java/com/thealgorithms/datastructures/caches/RRCacheTest.java @@ -155,4 +155,57 @@ void testTtlZeroThrowsIllegalArgumentException() { Executable exec = () -> new RRCache.Builder(3).defaultTTL(-1).build(); Assertions.assertThrows(IllegalArgumentException.class, exec); } + + @Test + void testPeriodicEvictionStrategyEvictsAtInterval() throws InterruptedException { + RRCache periodicCache = new RRCache.Builder(10) + .defaultTTL(50) + .evictionStrategy(new RRCache.PeriodicEvictionStrategy<>(3)) + .build(); + + periodicCache.put("x", "1"); + int ev1 = periodicCache.size(); + int ev2 = periodicCache.size(); + Thread.sleep(50); + int ev3 = periodicCache.size(); + + Assertions.assertEquals(1, ev1); + Assertions.assertEquals(1, ev2); + Assertions.assertEquals(0, ev3, "Eviction should happen on the 3rd access"); + Assertions.assertEquals(0, cache.size()); + } + + @Test + void testPeriodicEvictionStrategyThrowsExceptionIfIntervalLessThanOrEqual0() { + Executable executable = () -> new RRCache.Builder(10) + .defaultTTL(50) + .evictionStrategy(new RRCache.PeriodicEvictionStrategy<>(0)) + .build(); + + Assertions.assertThrows(IllegalArgumentException.class, executable); + } + + @Test + void testNoEvictionStrategyEvictsOnEachCall() throws InterruptedException { + RRCache noEvictionStrategyCache = new RRCache.Builder(10) + .defaultTTL(50) + .evictionStrategy(new RRCache.NoEvictionStrategy<>()) + .build(); + + noEvictionStrategyCache.put("x", "1"); + Thread.sleep(50); + int size = noEvictionStrategyCache.size(); + + Assertions.assertEquals(0, size); + } + + @Test + void testBuilderThrowsExceptionIfEvictionStrategyNull() { + Executable executable = () -> new RRCache.Builder(10) + .defaultTTL(50) + .evictionStrategy(null) + .build(); + + Assertions.assertThrows(IllegalArgumentException.class, executable); + } } From 0659a05642f33a5a9e63c162946f8fa8b9477b48 Mon Sep 17 00:00:00 2001 From: KevinMwita7 Date: Wed, 25 Jun 2025 19:46:18 +0300 Subject: [PATCH 09/10] Ran clang-format, make tests deterministic --- .../datastructures/caches/RRCache.java | 13 ++++- .../datastructures/caches/RRCacheTest.java | 57 +++++++++++++++---- 2 files changed, 56 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/thealgorithms/datastructures/caches/RRCache.java b/src/main/java/com/thealgorithms/datastructures/caches/RRCache.java index 2bf9ac57282c..1821872be9cd 100644 --- a/src/main/java/com/thealgorithms/datastructures/caches/RRCache.java +++ b/src/main/java/com/thealgorithms/datastructures/caches/RRCache.java @@ -212,7 +212,6 @@ private int evictExpired() { notifyEviction(k, entry.value); } } - return expiredCount; } @@ -303,6 +302,15 @@ public int size() { } } + /** + * Returns the current {@link EvictionStrategy} used by this cache instance. + + * @return the eviction strategy currently assigned to this cache + */ + public EvictionStrategy getEvictionStrategy() { + return evictionStrategy; + } + /** * Returns a string representation of the cache, including metadata and current non-expired entries. * @@ -355,7 +363,8 @@ public interface EvictionStrategy { * @param the type of values */ public static class NoEvictionStrategy implements EvictionStrategy { - @Override public int onAccess(RRCache cache) { + @Override + public int onAccess(RRCache cache) { return cache.evictExpired(); } } diff --git a/src/test/java/com/thealgorithms/datastructures/caches/RRCacheTest.java b/src/test/java/com/thealgorithms/datastructures/caches/RRCacheTest.java index f2737ae8f645..b1ab20569e99 100644 --- a/src/test/java/com/thealgorithms/datastructures/caches/RRCacheTest.java +++ b/src/test/java/com/thealgorithms/datastructures/caches/RRCacheTest.java @@ -164,15 +164,15 @@ void testPeriodicEvictionStrategyEvictsAtInterval() throws InterruptedException .build(); periodicCache.put("x", "1"); - int ev1 = periodicCache.size(); - int ev2 = periodicCache.size(); - Thread.sleep(50); - int ev3 = periodicCache.size(); - - Assertions.assertEquals(1, ev1); - Assertions.assertEquals(1, ev2); - Assertions.assertEquals(0, ev3, "Eviction should happen on the 3rd access"); - Assertions.assertEquals(0, cache.size()); + Thread.sleep(100); + int ev1 = periodicCache.getEvictionStrategy().onAccess(periodicCache); + int ev2 = periodicCache.getEvictionStrategy().onAccess(periodicCache); + int ev3 = periodicCache.getEvictionStrategy().onAccess(periodicCache); + + Assertions.assertEquals(0, ev1); + Assertions.assertEquals(0, ev2); + Assertions.assertEquals(1, ev3, "Eviction should happen on the 3rd access"); + Assertions.assertEquals(0, periodicCache.size()); } @Test @@ -193,10 +193,10 @@ void testNoEvictionStrategyEvictsOnEachCall() throws InterruptedException { .build(); noEvictionStrategyCache.put("x", "1"); - Thread.sleep(50); - int size = noEvictionStrategyCache.size(); + Thread.sleep(100); + int evicted = noEvictionStrategyCache.getEvictionStrategy().onAccess(noEvictionStrategyCache); - Assertions.assertEquals(0, size); + Assertions.assertEquals(1, evicted); } @Test @@ -208,4 +208,37 @@ void testBuilderThrowsExceptionIfEvictionStrategyNull() { Assertions.assertThrows(IllegalArgumentException.class, executable); } + + + @Test + void testReturnsCorrectStrategyInstance() { + RRCache.EvictionStrategy strategy = new RRCache.NoEvictionStrategy<>(); + + RRCache newCache = new RRCache.Builder(10) + .defaultTTL(1000) + .evictionStrategy(strategy) + .build(); + + Assertions.assertSame(strategy, newCache.getEvictionStrategy(), "Returned strategy should be the same instance"); + } + + @Test + void testDefaultStrategyIsNoEviction() { + RRCache newCache = new RRCache.Builder(5) + .defaultTTL(1000) + .build(); + + Assertions.assertTrue( + newCache.getEvictionStrategy() instanceof RRCache.PeriodicEvictionStrategy, + "Default strategy should be NoEvictionStrategy" + ); + } + + @Test + void testGetEvictionStrategyIsNotNull() { + RRCache newCache = new RRCache.Builder(5) + .build(); + + Assertions.assertNotNull(newCache.getEvictionStrategy(), "Eviction strategy should never be null"); + } } From 4432d1e351be7a9e202b17835ebf18e7b6c5bf0a Mon Sep 17 00:00:00 2001 From: KevinMwita7 Date: Wed, 25 Jun 2025 19:49:09 +0300 Subject: [PATCH 10/10] Ran clang-format --- .../datastructures/caches/RRCacheTest.java | 38 ++++--------------- 1 file changed, 8 insertions(+), 30 deletions(-) diff --git a/src/test/java/com/thealgorithms/datastructures/caches/RRCacheTest.java b/src/test/java/com/thealgorithms/datastructures/caches/RRCacheTest.java index b1ab20569e99..100c73ea2a5b 100644 --- a/src/test/java/com/thealgorithms/datastructures/caches/RRCacheTest.java +++ b/src/test/java/com/thealgorithms/datastructures/caches/RRCacheTest.java @@ -158,10 +158,7 @@ void testTtlZeroThrowsIllegalArgumentException() { @Test void testPeriodicEvictionStrategyEvictsAtInterval() throws InterruptedException { - RRCache periodicCache = new RRCache.Builder(10) - .defaultTTL(50) - .evictionStrategy(new RRCache.PeriodicEvictionStrategy<>(3)) - .build(); + RRCache periodicCache = new RRCache.Builder(10).defaultTTL(50).evictionStrategy(new RRCache.PeriodicEvictionStrategy<>(3)).build(); periodicCache.put("x", "1"); Thread.sleep(100); @@ -177,20 +174,14 @@ void testPeriodicEvictionStrategyEvictsAtInterval() throws InterruptedException @Test void testPeriodicEvictionStrategyThrowsExceptionIfIntervalLessThanOrEqual0() { - Executable executable = () -> new RRCache.Builder(10) - .defaultTTL(50) - .evictionStrategy(new RRCache.PeriodicEvictionStrategy<>(0)) - .build(); + Executable executable = () -> new RRCache.Builder(10).defaultTTL(50).evictionStrategy(new RRCache.PeriodicEvictionStrategy<>(0)).build(); Assertions.assertThrows(IllegalArgumentException.class, executable); } @Test void testNoEvictionStrategyEvictsOnEachCall() throws InterruptedException { - RRCache noEvictionStrategyCache = new RRCache.Builder(10) - .defaultTTL(50) - .evictionStrategy(new RRCache.NoEvictionStrategy<>()) - .build(); + RRCache noEvictionStrategyCache = new RRCache.Builder(10).defaultTTL(50).evictionStrategy(new RRCache.NoEvictionStrategy<>()).build(); noEvictionStrategyCache.put("x", "1"); Thread.sleep(100); @@ -201,43 +192,30 @@ void testNoEvictionStrategyEvictsOnEachCall() throws InterruptedException { @Test void testBuilderThrowsExceptionIfEvictionStrategyNull() { - Executable executable = () -> new RRCache.Builder(10) - .defaultTTL(50) - .evictionStrategy(null) - .build(); + Executable executable = () -> new RRCache.Builder(10).defaultTTL(50).evictionStrategy(null).build(); Assertions.assertThrows(IllegalArgumentException.class, executable); } - @Test void testReturnsCorrectStrategyInstance() { RRCache.EvictionStrategy strategy = new RRCache.NoEvictionStrategy<>(); - RRCache newCache = new RRCache.Builder(10) - .defaultTTL(1000) - .evictionStrategy(strategy) - .build(); + RRCache newCache = new RRCache.Builder(10).defaultTTL(1000).evictionStrategy(strategy).build(); Assertions.assertSame(strategy, newCache.getEvictionStrategy(), "Returned strategy should be the same instance"); } @Test void testDefaultStrategyIsNoEviction() { - RRCache newCache = new RRCache.Builder(5) - .defaultTTL(1000) - .build(); + RRCache newCache = new RRCache.Builder(5).defaultTTL(1000).build(); - Assertions.assertTrue( - newCache.getEvictionStrategy() instanceof RRCache.PeriodicEvictionStrategy, - "Default strategy should be NoEvictionStrategy" - ); + Assertions.assertTrue(newCache.getEvictionStrategy() instanceof RRCache.PeriodicEvictionStrategy, "Default strategy should be NoEvictionStrategy"); } @Test void testGetEvictionStrategyIsNotNull() { - RRCache newCache = new RRCache.Builder(5) - .build(); + RRCache newCache = new RRCache.Builder(5).build(); Assertions.assertNotNull(newCache.getEvictionStrategy(), "Eviction strategy should never be null"); } pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy