Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String>()
.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:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.github.reactivecircus.cache4k

import kotlin.time.Duration
import kotlin.time.TimeMark
import kotlin.time.TimeSource

/**
Expand Down Expand Up @@ -68,6 +69,12 @@ public interface Cache<in Key : Any, Value : Any> {
*/
public fun expireAfterAccess(duration: Duration): Builder<K, V>

/**
* 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<K, V>

/**
* 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.
Expand Down Expand Up @@ -110,8 +117,8 @@ public interface Cache<in Key : Any, Value : Any> {
internal class CacheBuilderImpl<K : Any, V : Any> : Cache.Builder<K, V> {

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<K, V>? = null
Expand All @@ -130,6 +137,10 @@ internal class CacheBuilderImpl<K : Any, V : Any> : Cache.Builder<K, V> {
this.expireAfterAccessDuration = duration
}

override fun expiresAt(expiresAt: (K, V) -> TimeMark?): Cache.Builder<K, V> = apply {
this.expiresAt = expiresAt
}

override fun maximumCacheSize(size: Long): CacheBuilderImpl<K, V> = apply {
require(size >= 0) {
"maximum size must not be negative"
Expand All @@ -149,6 +160,7 @@ internal class CacheBuilderImpl<K : Any, V : Any> : Cache.Builder<K, V> {
return RealCache(
expireAfterWriteDuration,
expireAfterAccessDuration,
expiresAt,
maxSize,
timeSource ?: TimeSource.Monotonic,
eventListener,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import kotlin.time.TimeSource
internal class RealCache<Key : Any, Value : Any>(
val expireAfterWriteDuration: Duration,
val expireAfterAccessDuration: Duration,
val expiresAt: ((Key, Value) -> TimeMark?)?,
val maxSize: Long,
val timeSource: TimeSource,
private val eventListener: CacheEventListener<Key, Value>?,
Expand Down Expand Up @@ -135,6 +136,7 @@ internal class RealCache<Key : Any, Value : Any>(
value = atomic(value),
accessTimeMark = atomic(nowTimeMark),
writeTimeMark = atomic(nowTimeMark),
expiresAt = expiresAt?.invoke(key, value)
)
recordWrite(newEntry)
cacheEntries.put(key, newEntry)
Expand Down Expand Up @@ -217,11 +219,12 @@ internal class RealCache<Key : Any, Value : Any>(
}

/**
* 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<Key, Value>.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()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume that isExpired() is called quite often. Maybe it is more efficient to only check expiresAt and update that field every time the entry is written or accessed.

}

/**
Expand Down Expand Up @@ -293,4 +296,5 @@ private class CacheEntry<Key : Any, Value : Any>(
val value: AtomicRef<Value>,
val accessTimeMark: AtomicRef<TimeMark>,
val writeTimeMark: AtomicRef<TimeMark>,
val expiresAt: TimeMark?,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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<Long, String>()
.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<Long, String>()
.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))
}
}