Merge pull request 'Add ExpiringMemoryCache implementation' (#1) from develop into master
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details

Reviewed-on: #1
This commit is contained in:
William Nolin 2022-01-30 17:59:35 -05:00
commit fb54b38819
7 changed files with 376 additions and 3 deletions

29
.drone.yml Normal file
View File

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

View File

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

View File

@ -1 +1,2 @@
kotlin.code.style=official
version=dev

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