commonMain.net.folivo.trixnity.client.store.cache.ObservableCache.kt Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of trixnity-client-jvm Show documentation
Show all versions of trixnity-client-jvm Show documentation
Multiplatform Kotlin SDK for matrix-protocol
package net.folivo.trixnity.client.store.cache
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlin.jvm.JvmInline
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
private val log = KotlinLogging.logger { }
internal sealed interface CacheValue {
class Init : CacheValue
@JvmInline
value class Value(val value: T) : CacheValue
fun valueOrNull() = when (this) {
is Init -> null
is Value -> value
}
}
/**
* The actual source and sink of the data to be cached. This could be any database.
*/
internal interface ObservableCacheStore {
/**
* Retrieve value from store.
*/
suspend fun get(key: K): V?
/**
* Save value to store.
*/
suspend fun persist(key: K, value: V?)
/**
* Delete all values from store.
*/
suspend fun deleteAll()
}
/**
* An index to track which entries have been added to or removed from the cache.
*/
internal interface ObservableMapIndex {
/**
* Called, when an entry is added to the cache.
*/
suspend fun onPut(key: K)
/**
* Called, when an entry is removed from the cache.
*
* @param stale means that the value has been deleted from the database. It is only set to true, when no-one listens to this specific key.
*/
suspend fun onRemove(key: K, stale: Boolean)
/**
* Called, when all entries are removed from the cache.
*/
suspend fun onRemoveAll()
/**
* Get the subscription count on an index entry, which uses an entry of the cache.
*/
suspend fun getSubscriptionCount(key: K): Flow
}
/**
* Base class to create a coroutine and [StateFlow] based cache.
*
* @param name The name is just used for logging.
* @param cacheScope A long living [CoroutineScope] to spawn coroutines, which remove entries from cache when not used anymore.
* @param expireDuration Duration to wait until entries from cache are when not used anymore.
* @param removeFromCacheOnNull removes an entry from the cache, when the value is null.
*/
internal open class ObservableCache>(
name: String,
protected val store: S,
cacheScope: CoroutineScope,
expireDuration: Duration = 1.minutes,
removeFromCacheOnNull: Boolean = false,
values: ConcurrentObservableMap>> = ConcurrentObservableMap(),
) : ObservableCacheBase(
name = name,
cacheScope = cacheScope,
expireDuration = expireDuration,
removeFromCacheOnNull = removeFromCacheOnNull,
values = values,
) {
suspend fun deleteAll() {
store.deleteAll()
clear()
}
fun read(key: K): Flow = flow {
emitAll(
updateAndGet(
key = key,
get = { store.get(key) },
)
)
}
suspend fun write(
key: K,
persistEnabled: Boolean = true,
onPersist: (newValue: V?) -> Unit = {},
updater: suspend (oldValue: V?) -> V?,
) {
updateAndGet(
key = key,
updater = updater,
get = { store.get(key) },
persist = { newValue ->
if (persistEnabled) store.persist(key, newValue).also { onPersist(newValue) }
},
)
}
suspend fun write(
key: K,
value: V?,
persistEnabled: Boolean = true,
onPersist: (newValue: V?) -> Unit = {},
) {
updateAndGet(
key = key,
updater = { value },
persist = { newValue ->
if (persistEnabled) store.persist(key, newValue).also { onPersist(newValue) }
},
)
}
}
internal open class ObservableCacheBase(
protected val name: String,
protected val cacheScope: CoroutineScope,
protected val expireDuration: Duration = 1.minutes,
protected val removeFromCacheOnNull: Boolean = false,
private val values: ConcurrentObservableMap>> = ConcurrentObservableMap(),
) {
init {
addIndex(RemoverJobExecutingIndex(name, values, cacheScope, expireDuration))
}
fun addIndex(index: ObservableMapIndex) {
values.indexes.update { it + index }
}
suspend fun clear() {
values.removeAll()
}
protected suspend fun updateAndGet(
key: K,
updater: (suspend (oldValue: V?) -> V?)? = null,
get: (suspend () -> V?)? = null,
persist: (suspend (newValue: V?) -> Unit)? = null,
): Flow {
val cacheEntry = values.getOrPut(key) {
MutableStateFlow>(CacheValue.Init())
.also { log.trace { "$name: no cache hit for key $key" } }
}
cacheEntry.first() // resets expire duration by increasing subscription count for a moment
val newValue = cacheEntry.updateAndGet(updater, get, persist)
if (removeFromCacheOnNull && updater != null && newValue == null) {
log.trace { "$name: remove value from cache with key $key because it is stale and is allowed to remove (will never be not-null again)" }
values.remove(key, true)
}
return cacheEntry.filterIsInstance>().map { it.value }
}
private suspend fun MutableStateFlow>.updateAndGet(
updater: (suspend (oldValue: V?) -> V?)? = null,
get: (suspend () -> V?)? = null,
persist: (suspend (newValue: V?) -> Unit)? = null,
): V? {
while (true) {
val oldRawValue = value
val oldValue = when (oldRawValue) {
is CacheValue.Init -> get?.invoke()
is CacheValue.Value -> oldRawValue.value
}
val newValue = if (updater != null) updater(oldValue) else oldValue
val newRawValue = CacheValue.Value(newValue)
if (compareAndSet(oldRawValue, newRawValue)) {
// There is a tiny chance, that on concurrent persists the wrong value is persisted.
// In trixnity-client this is very unlikely to happen as entities of the same type are usually updated sequentially.
if (persist != null && (oldValue != newValue || get == null)) persist(newValue)
return newValue
}
}
}
}
private class RemoverJobExecutingIndex(
private val name: String,
private val values: ConcurrentObservableMap>>,
private val cacheScope: CoroutineScope,
private val expireDuration: Duration = 1.minutes,
) : ObservableMapIndex {
private val infiniteCache = expireDuration.isInfinite()
override suspend fun onPut(key: K) {
if (infiniteCache.not()) {
val value = values.get(key) ?: return
cacheScope.launch {
log.trace { "$name: launch remover job for key $key" }
combine(
value.subscriptionCount,
values.getIndexSubscriptionCount(key),
) { subscriptionCount, indexSubscriptionCount ->
subscriptionCount to indexSubscriptionCount
}.collectLatest { (subscriptionCount, indexSubscriptionCount) ->
delay(expireDuration)
val stale = value.value.valueOrNull() == null
log.trace { "$name: remover job check for key $key (subscriptionCount=$subscriptionCount, indexSubscriptionCount=$indexSubscriptionCount, stale=$stale)" }
// indexSubscriptionCount currently means, that a collection of entries is subscribed.
// Therefore, it's okay to remove cache entries. The index would just update its list.
if (subscriptionCount == 0 && (stale || indexSubscriptionCount == 0)) {
log.trace { "$name: remove value from cache with key $key" }
values.remove(key, stale)
}
}
}
}
}
override suspend fun onRemove(key: K, stale: Boolean) {}
override suspend fun onRemoveAll() {}
override suspend fun getSubscriptionCount(key: K): StateFlow = MutableStateFlow(0)
}