Add expiring memory cache
This commit is contained in:
parent
8060ded947
commit
5f56af3cd0
|
@ -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")
|
|
@ -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
|
|
@ -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>() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
Loading…
Reference in New Issue