Add ExpiringMemoryCache implementation #1
|
@ -0,0 +1,29 @@
|
|||
---
|
||||
global-variables:
|
||||
gradle-image: &gradle-image gradle:7.1-jdk11
|
||||
|
||||
kind: pipeline
|
||||
name: default
|
||||
type: docker
|
||||
|
||||
steps:
|
||||
- name: gradle-test
|
||||
image: *gradle-image
|
||||
commands:
|
||||
- gradle test
|
||||
when:
|
||||
branch: develop
|
||||
|
||||
- name: publish
|
||||
image: *gradle-image
|
||||
environment:
|
||||
MAVEN_REPOSITORY_URL: https://archiva.fyloz.dev/repository/internal/
|
||||
MAVEN_REPOSITORY_USERNAME:
|
||||
from_secret: maven_repository_username
|
||||
MAVEN_REPOSITORY_PASSWORD:
|
||||
from_secret: maven_repository_password
|
||||
commands:
|
||||
- gradle publish -Pversion=${DRONE_TAG}
|
||||
when:
|
||||
event:
|
||||
- tag
|
|
@ -1,8 +1,8 @@
|
|||
group = "dev.fyloz"
|
||||
version = "1.0"
|
||||
|
||||
plugins {
|
||||
id("org.jetbrains.kotlin.jvm") version "1.6.10"
|
||||
id("maven-publish")
|
||||
}
|
||||
|
||||
repositories {
|
||||
|
@ -21,6 +21,39 @@ dependencies {
|
|||
testImplementation("io.mockk:mockk:1.12.2")
|
||||
}
|
||||
|
||||
tasks.withType<Test> {
|
||||
useJUnitPlatform()
|
||||
publishing {
|
||||
publications {
|
||||
create<MavenPublication>("memory-cache") {
|
||||
from(components["kotlin"])
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
maven {
|
||||
val repoUrl = System.getenv("MAVEN_REPOSITORY_URL")
|
||||
val repoUsername = System.getenv("MAVEN_REPOSITORY_USERNAME")
|
||||
val repoPassword = System.getenv("MAVEN_REPOSITORY_PASSWORD")
|
||||
val repoName = System.getenv("MAVEN_REPOSITORY_NAME") ?: "Archiva"
|
||||
|
||||
if (repoUrl != null && repoUsername != null && repoPassword != null) {
|
||||
url = uri(repoUrl)
|
||||
name = repoName
|
||||
|
||||
credentials {
|
||||
username = repoUsername
|
||||
password = repoPassword
|
||||
}
|
||||
} else {
|
||||
print("Some maven repository credentials were not configured, publishing is not configured")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
|
||||
testLogging {
|
||||
events("failed")
|
||||
}
|
||||
}
|
|
@ -1 +1,2 @@
|
|||
kotlin.code.style=official
|
||||
version=dev
|
|
@ -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