diff --git a/README.md b/README.md index a1eac6c..de8c6af 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,22 @@ An entry in this cache will be removed if it has not been replaced **after 30 mi _Note that cache entries are **not** removed immediately upon expiration at exact time. Expirations are checked in each interaction with the `cache`._ + +##### Dynamic expiration time + +To set an specific expiration time for an entry, you can specify a function that takes the cache key and value +and returns a time for when it should expire, or null if the entry does not have an expiration time. + + +```kotlin +val cache = Cache.Builder() + .expiresAt { key, value -> key.length.minutes } + .build() +``` + +An entry in this cache will be removed when the current time passed the time mark returned by the function, or never if the returned time is null. + + ### Size-based eviction To set the maximum number of entries to be kept in the cache: diff --git a/cache4k/src/commonMain/kotlin/io/github/reactivecircus/cache4k/Cache.kt b/cache4k/src/commonMain/kotlin/io/github/reactivecircus/cache4k/Cache.kt index 120ca44..e3e483d 100644 --- a/cache4k/src/commonMain/kotlin/io/github/reactivecircus/cache4k/Cache.kt +++ b/cache4k/src/commonMain/kotlin/io/github/reactivecircus/cache4k/Cache.kt @@ -1,6 +1,7 @@ package io.github.reactivecircus.cache4k import kotlin.time.Duration +import kotlin.time.TimeMark import kotlin.time.TimeSource /** @@ -68,6 +69,12 @@ public interface Cache { */ public fun expireAfterAccess(duration: Duration): Builder + /** + * Specifies a function that takes the cache entry key and value, and returns a [TimeMark] at which + * time the entry should expire, or null if the entry does not have a set expiration time. + */ + public fun expiresAt(expiresAt: (K, V) -> TimeMark?): Builder + /** * Specifies the maximum number of entries the cache may contain. * Cache eviction policy is based on LRU - i.e. least recently accessed entries get evicted first. @@ -110,8 +117,8 @@ public interface Cache { internal class CacheBuilderImpl : Cache.Builder { private var expireAfterWriteDuration = Duration.INFINITE - private var expireAfterAccessDuration = Duration.INFINITE + private var expiresAt: ((K, V) -> TimeMark?)? = null private var maxSize = UNSET_LONG private var timeSource: TimeSource? = null private var eventListener: CacheEventListener? = null @@ -130,6 +137,10 @@ internal class CacheBuilderImpl : Cache.Builder { this.expireAfterAccessDuration = duration } + override fun expiresAt(expiresAt: (K, V) -> TimeMark?): Cache.Builder = apply { + this.expiresAt = expiresAt + } + override fun maximumCacheSize(size: Long): CacheBuilderImpl = apply { require(size >= 0) { "maximum size must not be negative" @@ -149,6 +160,7 @@ internal class CacheBuilderImpl : Cache.Builder { return RealCache( expireAfterWriteDuration, expireAfterAccessDuration, + expiresAt, maxSize, timeSource ?: TimeSource.Monotonic, eventListener, diff --git a/cache4k/src/commonMain/kotlin/io/github/reactivecircus/cache4k/RealCache.kt b/cache4k/src/commonMain/kotlin/io/github/reactivecircus/cache4k/RealCache.kt index 48e2541..f33962d 100644 --- a/cache4k/src/commonMain/kotlin/io/github/reactivecircus/cache4k/RealCache.kt +++ b/cache4k/src/commonMain/kotlin/io/github/reactivecircus/cache4k/RealCache.kt @@ -32,6 +32,7 @@ import kotlin.time.TimeSource internal class RealCache( val expireAfterWriteDuration: Duration, val expireAfterAccessDuration: Duration, + val expiresAt: ((Key, Value) -> TimeMark?)?, val maxSize: Long, val timeSource: TimeSource, private val eventListener: CacheEventListener?, @@ -135,6 +136,7 @@ internal class RealCache( value = atomic(value), accessTimeMark = atomic(nowTimeMark), writeTimeMark = atomic(nowTimeMark), + expiresAt = expiresAt?.invoke(key, value) ) recordWrite(newEntry) cacheEntries.put(key, newEntry) @@ -217,11 +219,12 @@ internal class RealCache( } /** - * Check whether the [CacheEntry] has expired based on either access time or write time. + * Check whether the [CacheEntry] has expired based on either access time, write time, or set expiration time. */ private fun CacheEntry.isExpired(): Boolean { - return expiresAfterAccess && (accessTimeMark.value + expireAfterAccessDuration).hasPassedNow() || - expiresAfterWrite && (writeTimeMark.value + expireAfterWriteDuration).hasPassedNow() + return expiresAfterAccess && (accessTimeMark.value + expireAfterAccessDuration).hasPassedNow() + || expiresAfterWrite && (writeTimeMark.value + expireAfterWriteDuration).hasPassedNow() + || expiresAt != null && expiresAt.hasPassedNow() } /** @@ -293,4 +296,5 @@ private class CacheEntry( val value: AtomicRef, val accessTimeMark: AtomicRef, val writeTimeMark: AtomicRef, + val expiresAt: TimeMark?, ) diff --git a/cache4k/src/commonTest/kotlin/io/github/reactivecircus/cache4k/CacheExpiryTest.kt b/cache4k/src/commonTest/kotlin/io/github/reactivecircus/cache4k/CacheExpiryTest.kt index d922b32..62e4d9e 100644 --- a/cache4k/src/commonTest/kotlin/io/github/reactivecircus/cache4k/CacheExpiryTest.kt +++ b/cache4k/src/commonTest/kotlin/io/github/reactivecircus/cache4k/CacheExpiryTest.kt @@ -242,4 +242,42 @@ class CacheExpiryTest { assertEquals("cat", cache.get(2)) assertEquals("bird", cache.get(3)) } + + @Test + fun expiresAtEntryEvictedAfterExpirationTimeSet() { + val dog = "dog" + + val cache = Cache.Builder() + .timeSource(fakeTimeSource) + .expiresAt { _, value -> fakeTimeSource.markNow() + value.length.minutes } + .build() + + cache.put(1, dog) + assertEquals(dog, cache.get(1)) + + fakeTimeSource += 1.minutes + assertEquals(dog, cache.get(1)) + + fakeTimeSource += dog.length.minutes + assertNull(cache.get(1)) + } + + @Test + fun expiresAtEntryNoEvictionWhenNoExpirationTimeSet() { + val dog = "dog" + + val cache = Cache.Builder() + .timeSource(fakeTimeSource) + .expiresAt { _, _ -> null } + .build() + + cache.put(1, dog) + assertEquals(dog, cache.get(1)) + + fakeTimeSource += 1.minutes + assertEquals(dog, cache.get(1)) + + fakeTimeSource += dog.length.minutes + assertEquals(dog, cache.get(1)) + } }