From 5f56af3cd0d2f76c52742ce257a12637587edc57 Mon Sep 17 00:00:00 2001 From: FyloZ Date: Sun, 30 Jan 2022 17:13:53 -0500 Subject: [PATCH 1/2] Add expiring memory cache --- .../fyloz/memorycache/ExpiringMemoryCache.kt | 129 ++++++++++++++++++ .../fyloz/memorycache/utils/Collections.kt | 41 ++++++ .../memorycache/ExpiringMemoryCacheTest.kt | 98 +++++++++++++ .../memorycache/utils/CollectionsTest.kt | 42 ++++++ 4 files changed, 310 insertions(+) create mode 100644 src/main/kotlin/dev/fyloz/memorycache/ExpiringMemoryCache.kt create mode 100644 src/main/kotlin/dev/fyloz/memorycache/utils/Collections.kt create mode 100644 src/test/kotlin/dev/fyloz/memorycache/ExpiringMemoryCacheTest.kt create mode 100644 src/test/kotlin/dev/fyloz/memorycache/utils/CollectionsTest.kt 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 -- 2.40.1 From 8b8dcb7ebe95b8f82bcea5feb153b370dd9e50f6 Mon Sep 17 00:00:00 2001 From: FyloZ Date: Sun, 30 Jan 2022 17:49:22 -0500 Subject: [PATCH 2/2] Add CI/CD --- .drone.yml | 29 +++++++++++++++++++++++++++++ build.gradle.kts | 37 +++++++++++++++++++++++++++++++++++-- gradle.properties | 3 ++- 3 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 .drone.yml diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..a54df32 --- /dev/null +++ b/.drone.yml @@ -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 \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 691808c..4422952 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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 { +publishing { + publications { + create("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") + } } \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 29e08e8..89645e5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1,2 @@ -kotlin.code.style=official \ No newline at end of file +kotlin.code.style=official +version=dev \ No newline at end of file -- 2.40.1