Add ExpiringMemoryCache implementation #1

Merged
william merged 2 commits from develop into master 2022-01-30 17:59:36 -05:00
4 changed files with 310 additions and 0 deletions
Showing only changes of commit 5f56af3cd0 - Show all commits

View File

@ -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<K, V>(
private val maxAccessCount: Long = -1,
private val maxLifetimeSeconds: Long = -1
) : BaseMemoryCache<K, V>() {
private val keys: TreeSet<ExpiringKey<K>>
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<ExpiringKey<*>> = 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<ExpiringKey<K>>.removeExpiringKey(expiringKey: ExpiringKey<K>) {
remove()
super.remove(expiringKey.key)
}
private fun getNewExpiringKey(key: K) =
ExpiringKey(key, accessCount, getCurrentTime())
private fun getCurrentTime() =
Instant.now().toEpochMilli()
}
private data class ExpiringKey<K>(val key: K, var lastAccessCount: Long, var lastAccessMillis: Long) {
companion object {
internal val accessCountComparator: Comparator<ExpiringKey<*>> =
Comparator.comparingLong<ExpiringKey<*>> { it.lastAccessCount }
.thenComparingLong { it.lastAccessMillis }
.thenComparingInt { it.key.hashCode() }
internal val timeComparator: Comparator<ExpiringKey<*>> =
Comparator.comparingLong<ExpiringKey<*>> { it.lastAccessMillis }
.thenComparingLong { it.lastAccessCount }
.thenComparingInt { it.key.hashCode() }
}
}
internal class InvalidExpiringCacheOptionsException :
RuntimeException("An expiration cache must have at least one expiration method configured")

View File

@ -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 <T> MutableIterable<T>.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 <T> Iterable<T>.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

View File

@ -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<Int, String>(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<Int, String>(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<InvalidExpiringCacheOptionsException> { ExpiringMemoryCache<Int, String>() }
}
}
})

View File

@ -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
}
}
})