commonMain.ExpirationCache.kt Maven / Gradle / Ivy
package opensavvy.cache
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import opensavvy.logger.Logger.Companion.trace
import opensavvy.logger.loggerFor
import opensavvy.progress.done
import opensavvy.state.coroutines.ProgressiveFlow
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
/**
* Cache layer that expires values from the previous layer after a specified [duration][expireAfter].
*
* To add an [ExpirationCache] to a previous layer, use [Cache.expireAfter][Cache.expireAfter]:
* ```kotlin
* val cache = Cache.Default
* .expireAfter(5.minutes, Job())
* ```
*/
internal class ExpirationCache(
/**
* The previous cache layer, from which values will be expired.
*/
private val upstream: Cache,
/**
* After how much time should the values from the previous cache layer be expired.
*
* The values will not be expired exactly this time after the last update.
* Instead, the implementation guarantees that the data will be expired between [expireAfter] and [expireAfter] * 2.
*/
private val expireAfter: Duration = 5.minutes,
private val clock: Clock,
/**
* The asynchronous context in which the cleaner runs.
*
* Cancelling this job will cancel the expiration job, after which this cache will stop expiring data.
*/
expirationScope: CoroutineScope,
) : Cache {
private val log = loggerFor(this)
private val lastUpdate = HashMap()
private val lock = Mutex()
init {
expirationScope.launch(CoroutineName("$this")) {
while (isActive) {
delay(expireAfter)
lock.withLock("checkExpiredValues()") {
val now = clock.now()
val iterator = lastUpdate.iterator()
while (iterator.hasNext()) {
val (id, instant) = iterator.next()
if (instant < now - expireAfter) {
log.trace(id) { "Expired value:" }
iterator.remove()
upstream.expire(id)
}
}
}
}
}
}
private suspend fun markAsUpdatedNow(id: I) {
lock.withLock("markAsUpdatedNow($id)") {
log.trace(id) { "Updated now:" }
lastUpdate[id] = clock.now()
}
}
override fun get(id: I): ProgressiveFlow = upstream[id]
.onEach {
if (it.progress == done())
markAsUpdatedNow(id)
// else: it's still loading, no need to count it as done
}
override suspend fun update(values: Collection>) {
// When the upstream is updated, it will signal the modification through the 'get' function,
// which will catch. No need to update anything else here.
upstream.update(values)
}
override suspend fun expire(ids: Collection) {
for (ref in ids)
lock.withLock("expire($ids)") {
lastUpdate.remove(ref)
}
upstream.expire(ids)
}
override suspend fun expireAll() {
lock.withLock("expireAll()") {
lastUpdate.clear()
}
upstream.expireAll()
}
companion object
}
/**
* Age-based [Cache] expiration strategy.
*
* ### General behavior
*
* The cache starts a worker in [scope]. Every [duration], all values which have not been updated
* for at least [duration] are expired in the previous layer.
*
* This layer considers any non-loading value returned by [get][Cache.get] to be new.
*
* If [scope] is cancelled, requests made to this cache continue to work as normal, but no values are ever expired anymore.
*
* ### Example
*
* ```kotlin
* val scope: CoroutineScope = …
*
* val powersOfTwo = cache { it * 2 }
* .cachedInMemory(scope.coroutineContext.job)
* .expireAfter(10.minutes, scope, clock)
* ```
*/
fun Cache.expireAfter(duration: Duration, scope: CoroutineScope, clock: Clock): Cache =
ExpirationCache(this, duration, clock, scope)
@Suppress("DeprecatedCallableAddReplaceWith")
@Deprecated(message = "Specifying the clock explicitly will become mandatory in 2.0.")
fun Cache.expireAfter(duration: Duration, scope: CoroutineScope): Cache =
ExpirationCache(this, duration, Clock.System, scope)