diff --git a/src/main/kotlin/dev/fyloz/memorycache/ExpiringMemoryCache.kt b/src/main/kotlin/dev/fyloz/memorycache/ExpiringMemoryCache.kt new file mode 100644 index 0000000..1879aed --- /dev/null +++ b/src/main/kotlin/dev/fyloz/memorycache/ExpiringMemoryCache.kt @@ -0,0 +1,129 @@ +package dev.fyloz.memorycache + +import dev.fyloz.memorycache.utils.removeFirst +import java.time.Instant +import java.util.* + +/** + * A key-value memory cache allowing for expiring keys. + * + * The key's expiration can be setup with two methods: + * - After a number of access to the cache + * - After a duration + * + * Either methods can be used, or both. But at least one need to be configured. + * Note that using a single method is more optimized, since the keys can be sorted easily. + * + * For both methods, the lifetime of the key will be renewed after it has been set or accessed. + * + * @param K The type of the keys. + * @param V The type of the values. + * @property maxAccessCount The number of access to the cache before a key expires. + * @property maxLifetimeSeconds The duration of a key before its expiration in seconds. + */ +class ExpiringMemoryCache( + private val maxAccessCount: Long = -1, + private val maxLifetimeSeconds: Long = -1 +) : BaseMemoryCache() { + private val keys: TreeSet> + private var accessCount = 0L + + private val maxLifetimeMillis = maxLifetimeSeconds * 1000 + private val expireByAccessCount: Boolean = maxAccessCount > 0 + private val expireByAccessTime: Boolean = maxLifetimeSeconds > 0 + + init { + // Determine the correct key comparator to use + val keysComparator: Comparator> = if (expireByAccessCount) { + ExpiringKey.accessCountComparator + } else if (expireByAccessTime) { + ExpiringKey.timeComparator + } else { + throw InvalidExpiringCacheOptionsException() + } + + keys = TreeSet(keysComparator) + } + + override fun get(key: K): V? { + accessCount++ + + cleanup() + renewKey(key) + + return super.get(key) + } + + override fun set(key: K, value: V) { + super.set(key, value) + + renewKey(key) + } + + override fun remove(key: K) { + super.remove(key) + keys.removeFirst { it.key == key } + } + + override fun clear() { + super.clear() + keys.clear() + + accessCount = 0 + } + + private fun renewKey(key: K) { + keys.removeFirst { it.key == key } + keys.add(getNewExpiringKey(key)) + } + + private fun cleanup() { + with(keys.iterator()) { + while (hasNext()) { + val expiringKey = next() + if (expireByAccessCount) { + if (accessCount - expiringKey.lastAccessCount > maxAccessCount) { + removeExpiringKey(expiringKey) + } else if (!expireByAccessTime) { + break // Keys are sorted, so if this key is not expired, the next ones are not + } + } + if (expireByAccessTime) { + if (getCurrentTime() - expiringKey.lastAccessMillis > maxLifetimeMillis) { + removeExpiringKey(expiringKey) + } else if (!expireByAccessCount) { + break + } + } + } + } + } + + private fun MutableIterator>.removeExpiringKey(expiringKey: ExpiringKey) { + remove() + super.remove(expiringKey.key) + } + + private fun getNewExpiringKey(key: K) = + ExpiringKey(key, accessCount, getCurrentTime()) + + private fun getCurrentTime() = + Instant.now().toEpochMilli() +} + +private data class ExpiringKey(val key: K, var lastAccessCount: Long, var lastAccessMillis: Long) { + companion object { + internal val accessCountComparator: Comparator> = + Comparator.comparingLong> { it.lastAccessCount } + .thenComparingLong { it.lastAccessMillis } + .thenComparingInt { it.key.hashCode() } + + internal val timeComparator: Comparator> = + Comparator.comparingLong> { it.lastAccessMillis } + .thenComparingLong { it.lastAccessCount } + .thenComparingInt { it.key.hashCode() } + } +} + +internal class InvalidExpiringCacheOptionsException : + RuntimeException("An expiration cache must have at least one expiration method configured") \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/memorycache/utils/Collections.kt b/src/main/kotlin/dev/fyloz/memorycache/utils/Collections.kt new file mode 100644 index 0000000..1e120d7 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/memorycache/utils/Collections.kt @@ -0,0 +1,41 @@ +package dev.fyloz.memorycache.utils + +/** + * Removes the first element matching the given [predicate] in this [MutableIterable]. + * Returns true if any element was removed. + */ +inline fun MutableIterable.removeFirst(predicate: (T) -> Boolean): Boolean { + with(iterator()) { + while (hasNext()) { + if (predicate(next())) { + remove() + return true + } + } + } + + return false +} + +/** Gets the nth item in this [Iterable]. */ +inline fun Iterable.nth(n: Int, predicate: (T) -> Boolean): T? { + var matches = 0 + + for (item in this) { + if (!predicate(item)) { + continue + } + + matches++ + + if (matches == n) { + return item + } + } + + return null +} + +/** Is this [Int] is even or not. */ +val Int.isEven: Boolean + get() = this % 2 == 0 diff --git a/src/test/kotlin/dev/fyloz/memorycache/ExpiringMemoryCacheTest.kt b/src/test/kotlin/dev/fyloz/memorycache/ExpiringMemoryCacheTest.kt new file mode 100644 index 0000000..6b261c4 --- /dev/null +++ b/src/test/kotlin/dev/fyloz/memorycache/ExpiringMemoryCacheTest.kt @@ -0,0 +1,98 @@ +package dev.fyloz.memorycache + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class ExpiringMemoryCacheTest : ShouldSpec({ + context("expiration by access count") { + val maxAccessCount = 5L + val cache = ExpiringMemoryCache(maxAccessCount = maxAccessCount) + + val accessedKey = 0 + val expiringKey = Int.MAX_VALUE + + should("removes expired keys") { + // Arrange + cache[accessedKey] = accessedKey.toString() + cache[expiringKey] = expiringKey.toString() + + // Act + for (i in 0..maxAccessCount) { + cache[accessedKey] + } + + // Assert + val containsUnusedKey = expiringKey in cache + containsUnusedKey shouldBe false + } + + should("renew keys on access") { + // Arrange + cache[accessedKey] = accessedKey.toString() + cache[expiringKey] = expiringKey.toString() + + for (i in 0 until maxAccessCount - 1) { + cache[accessedKey] + } + + // Act + cache[expiringKey] + + // Assert + val containsUnusedKey = expiringKey in cache + containsUnusedKey shouldBe true + } + } + + context("expiration by access time") { + val maxLifetimeSeconds = 2L + val cache = ExpiringMemoryCache(maxLifetimeSeconds = maxLifetimeSeconds) + + val key = Int.MAX_VALUE + + suspend fun wait(seconds: Long) = + withContext(Dispatchers.IO) { + Thread.sleep(seconds * 1000) + } + + should("removes expired keys") { + // Arrange + cache[key] = key.toString() + + // Act + wait(maxLifetimeSeconds) + cache[key] + + // Assert + val containsKey = key in cache + containsKey shouldBe false + } + + should("renew keys on access") { + // Arrange + cache[key] = key.toString() + + // Act + wait(maxLifetimeSeconds / 2) + cache[key] // Access key before expiration + wait(maxLifetimeSeconds / 2) + cache[key] + + // Assert + val containsKey = key in cache + containsKey shouldBe true + } + } + + context("no expiration method defined") { + should("throws InvalidExpiringCacheOptionsException") { + // Arrange + // Act + // Assert + shouldThrow { ExpiringMemoryCache() } + } + } +}) \ No newline at end of file diff --git a/src/test/kotlin/dev/fyloz/memorycache/utils/CollectionsTest.kt b/src/test/kotlin/dev/fyloz/memorycache/utils/CollectionsTest.kt new file mode 100644 index 0000000..7d692c7 --- /dev/null +++ b/src/test/kotlin/dev/fyloz/memorycache/utils/CollectionsTest.kt @@ -0,0 +1,42 @@ +package dev.fyloz.memorycache.utils + +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe + +class CollectionsTest : ShouldSpec({ + context("removeFirst") { + val collection = setOf(1, 2, 3, 4, 5) + + fun getMutableCollection() = mutableSetOf(*collection.toTypedArray()) + + var mutableCollection = getMutableCollection() + + beforeEach { + mutableCollection = getMutableCollection() + } + + should("remove first item matching predicate from collection") { + // Arrange + val firstEvenItem = mutableCollection.first { it.isEven } + + // Act + mutableCollection.removeFirst { it.isEven } + + // Assert + val contains = firstEvenItem in mutableCollection + contains shouldBe false + } + + should("does not remove second item matching predicate from collection") { + // Arrange + val secondEvenItem = mutableCollection.nth(2) { it.isEven } + + // Act + mutableCollection.removeFirst { it.isEven } + + // Assert + val contains = secondEvenItem in mutableCollection + contains shouldBe true + } + } +}) \ No newline at end of file