From 884fa295a52f2838abd2c880ffb94b115e301d0c Mon Sep 17 00:00:00 2001 From: KevinMwita7 Date: Fri, 20 Jun 2025 22:05:41 +0300 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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); } }