diff --git a/template/gradle/libs.versions.toml b/template/gradle/libs.versions.toml index cd1cb8ef..4f9cc1b2 100644 --- a/template/gradle/libs.versions.toml +++ b/template/gradle/libs.versions.toml @@ -43,6 +43,7 @@ koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -76,6 +77,7 @@ sqldelight-sqlite-driver = { module = "app.cash.sqldelight:sqlite-driver", versi sqldelight-web-worker-driver = { module = "app.cash.sqldelight:web-worker-driver", version.ref = "sqldelight" } touchlab-kermit = { module = "co.touchlab:kermit", version.ref = "touchlab-kermit" } touchlab-stately-common = { module = "co.touchlab:stately-common", version.ref = "touchlab-stately" } +touchlab-stately-concurrent-collections = { module = "co.touchlab:stately-concurrent-collections", version.ref = "touchlab-stately" } touchlab-stately-isolate = { module = "co.touchlab:stately-isolate", version.ref = "touchlab-stately" } touchlab-stately-iso-collections = { module = "co.touchlab:stately-iso-collections", version.ref = "touchlab-stately" } touchlab-stately-iso-collections-js = { module = "co.touchlab:stately-iso-collections-js", version.ref = "touchlab-stately" } diff --git a/template/shared/data/build.gradle.kts b/template/shared/data/build.gradle.kts index 579ef8f4..74e23f28 100644 --- a/template/shared/data/build.gradle.kts +++ b/template/shared/data/build.gradle.kts @@ -36,10 +36,15 @@ kotlin { } } commonMain.dependencies { + api(libs.kotlinx.datetime) api(libs.bundles.ktor.common) api(libs.kotlinx.coroutines.core) api(libs.kotlinx.serialization.json) implementation(libs.multiplatform.settings.no.arg) + implementation(libs.touchlab.stately.concurrent.collections) + } + commonTest.dependencies { + implementation(libs.kotlin.test) } // {platform.android.dependencies} androidMain.dependencies { diff --git a/template/shared/data/src/commonMain/kotlin/shared/data/datasource/cache/CacheKey.kt b/template/shared/data/src/commonMain/kotlin/shared/data/datasource/cache/CacheKey.kt new file mode 100644 index 00000000..be005317 --- /dev/null +++ b/template/shared/data/src/commonMain/kotlin/shared/data/datasource/cache/CacheKey.kt @@ -0,0 +1,63 @@ +package shared.data.datasource.cache + +/** + * Interface representing a cache key for storing and retrieving data in the cache. + * + * @param T The type of the cached data. + */ +interface CacheKey { + + /** + * Time-to-live (TTL) for the cached data, in milliseconds. + */ + val ttl: Long + + /** + * Determines if the cached data associated with this key is considered immortal. + * In terms of the API, immortality means that the cache entry initialization request + * is not bound to the lifecycle of the component from which it is called. + * + * For example, if a cache entry initialization request is called from a View (Fragment/Activity), + * it will be canceled once the view is destroyed. + * When a key is immortal, the cache entry will be initialized regardless of the component lifecycle. + * + * This can be useful when you need to proceed with some request without interruptions. + * For example, an OAuth refresh token actualization response needs to be completed and stored locally + * as an atomic action, so any further calls under OAuth authorization can continue with the newly obtained token. + * If such an action is processed on the server but interrupted and not stored on the client, + * it is possible that the old token becomes outdated and any further request to update it will fail. + * + * The immortal key helps reduce such issues by ensuring that cache entries are initialized + * even if the associated component lifecycle ends. + * + * @return {@code true} if the data associated with this key is immortal, {@code false} otherwise. + */ + fun immortal(): Boolean = false + + companion object { + const val TTL_UNLIMITED = -1L + const val TTL_1_SECOND = 1_000L + const val TTL_3_SECONDS = 3_000L + const val TTL_5_SECONDS = 5_000L + const val TTL_10_SECONDS = 10_000L + const val TTL_15_SECONDS = 15_000L + const val TTL_30_SECONDS = 30_000L + const val TTL_60_SECONDS = 60_000L + const val TTL_5_MINUTES = 5 * 60_000L + + /** + * Creates a new CacheKey with the specified time-to-live (TTL) duration. + * + * @param duration The time-to-live (TTL) duration for the cache key. + * @param immortal Specifies whether the cache key is immortal or not. Defaults to false. + * When set to true, the cache entry will not be bound to the lifecycle of the component + * from which it is initialized. + * @return A new CacheKey instance with the specified TTL duration and immortality status. + */ + fun of(duration: Long, immortal: Boolean = false): CacheKey = object : CacheKey { + override val ttl: Long = duration + override fun immortal(): Boolean = immortal + } + } + +} \ No newline at end of file diff --git a/template/shared/data/src/commonMain/kotlin/shared/data/datasource/cache/CacheSource.kt b/template/shared/data/src/commonMain/kotlin/shared/data/datasource/cache/CacheSource.kt new file mode 100644 index 00000000..02c9a86d --- /dev/null +++ b/template/shared/data/src/commonMain/kotlin/shared/data/datasource/cache/CacheSource.kt @@ -0,0 +1,76 @@ +package shared.data.datasource.cache + +import shared.data.datasource.DataSource +import kotlin.reflect.KClass + +/** + * Provides an interface for a basic thread-safe caching mechanism, serving as an L1 Cache. + * + * The cache allows for storing and retrieving any in-memory data efficiently. + * + * It supports operations such as getting, putting, removing, and invalidating cache entries. + */ +interface CacheSource : DataSource { + + /** + * Retrieves the state of a cache entry associated with the specified key. + * If the entry is not found in the cache, the provided value provider function is invoked to obtain the value. + * + * @param key The cache key associated with the entry. + * @param valueProvider A suspend function that provides the value if the cache entry is not found. + * @return A CacheState object representing the state of the cache entry. + */ + fun getState(key: CacheKey, valueProvider: suspend () -> T?): CacheState + + /** + * Retrieves the value associated with the specified key from the cache. + * If the value is not found in the cache, the provided value provider function is invoked to obtain the value. + * + * @param key The cache key associated with the value. + * @param valueProvider A suspend function that provides the value if it is not found in the cache. + * @return The value associated with the key, or null if not found. + */ + suspend fun get(key: CacheKey, valueProvider: suspend () -> T?): T? + + /** + * Invalidates all cache entries associated with the specified key type. + * + * @param type The type of cache keys to invalidate. + */ + fun > invalidate(type: KClass) + + /** + * Invalidates the cache entry associated with the specified key. + * + * @param key The cache key to invalidate. + */ + fun > invalidate(key: K) + + /** + * Removes all cache entries associated with the specified key type. + * + * @param type The type of cache keys to remove. + */ + fun > remove(type: KClass) + + /** + * Removes the cache entry associated with the specified key. + * + * @param key The cache key to remove. + */ + fun > remove(key: K) + + /** + * Associates the specified value with the specified key in the cache. + * + *@param key The cache key to associate with the value. + * @param value The value to be stored in the cache. + */ + fun put(key: CacheKey, value: T) + + /** + * Clears all entries from the cache. + */ + fun clear() + +} \ No newline at end of file diff --git a/template/shared/data/src/commonMain/kotlin/shared/data/datasource/cache/CacheState.kt b/template/shared/data/src/commonMain/kotlin/shared/data/datasource/cache/CacheState.kt new file mode 100644 index 00000000..98bf2f49 --- /dev/null +++ b/template/shared/data/src/commonMain/kotlin/shared/data/datasource/cache/CacheState.kt @@ -0,0 +1,69 @@ +package shared.data.datasource.cache + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +/** + * Represents the state of a cache entry. + * + * @param T The type of the cached item. + */ +interface CacheState { + + /** The key associated with this cache state. */ + val key: CacheKey + + /** + * Retrieves the cached item. + * + * @return The cached item, or new one if the item is not present in the cache or expired. + */ + suspend fun get(): T? + + /** + * Retrieves the last cached item. + * + * @return The last cached item, or null if the item is not present in the cache. + */ + suspend fun last(): T? + + /** + * Retrieves a fresh copy of the cached item. + * + * @return A fresh copy of the cached item, or null if the item is not available. + */ + suspend fun fresh(): T? + + /** + * Retrieves the last cached item if available, otherwise retrieves a fresh copy of the item. + * + * @return The last cached item if available, or a fresh copy of the item. Returns null if the item is not present in the cache. + */ + suspend fun lastOrFresh() = last() ?: fresh() + + /** + * Emits the cached item whenever it changes. + * The flow updates an item in the cache based on the expiration of the key. + * + * @return A flow representing the changes to the cached item. + */ + suspend fun changes(): Flow + + companion object { + /** + * Creates a CacheState instance representing a passed item. + * + * @param key The cache key associated with the item. + * @param item The cached item. + * @return A CacheState instance representing the single cached item. + */ + fun single(key: CacheKey, item: T): CacheState = object : CacheState { + override val key: CacheKey = key + override suspend fun get(): T? = item + override suspend fun last(): T? = item + override suspend fun fresh(): T? = item + override suspend fun changes(): Flow = flowOf(item) + } + } + +} \ No newline at end of file diff --git a/template/shared/data/src/commonMain/kotlin/shared/data/datasource/cache/InMemoryCacheSource.kt b/template/shared/data/src/commonMain/kotlin/shared/data/datasource/cache/InMemoryCacheSource.kt new file mode 100644 index 00000000..9ab6f2de --- /dev/null +++ b/template/shared/data/src/commonMain/kotlin/shared/data/datasource/cache/InMemoryCacheSource.kt @@ -0,0 +1,208 @@ +@file:OptIn(DelicateCoroutinesApi::class) +@file:Suppress("UNCHECKED_CAST") + +package shared.data.datasource.cache + +import co.touchlab.stately.collections.ConcurrentMutableMap +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.async +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.retry +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +import kotlinx.datetime.Clock +import shared.data.misc.extensions.isCancellationException +import kotlin.reflect.KClass + +/** + * Basic implementation of a thread-safe cache for storing and retrieving in-memory data. + * This cache can be utilized as an L1 Cache when managing HTTP requests, offering an efficient means + * to present data without delays, but with the ability to update based on expiration and other conditions. + * + * @param changesRetryInterval The interval, in milliseconds, to retry cache changes. + * @param exceptionRetryInterval The interval, in milliseconds, to retry cache operations in case of exceptions. + * @param exceptionRetryCount The maximum number of retries for cache operations in case of exceptions. + */ +open class InMemoryCacheSource( + private val changesRetryInterval: Long = 1000L, + private val exceptionRetryInterval: Long = 3000L, + private val exceptionRetryCount: Int = 10 +) : CacheSource { + + private val dispatcher = Dispatchers.Default + private val jobs = ConcurrentMutableMap>() + private val cache = ConcurrentMutableMap() + + override fun getState(key: CacheKey, valueProvider: suspend () -> T?): CacheState = + CacheStateImpl(key, valueProvider) + + override suspend fun get(key: CacheKey, valueProvider: suspend () -> T?): T? { + val cacheKey = CacheKeySnapshot(key) + val cacheItem = cache[cacheKey] + if (cacheItem == null || !cacheItem.isValid(key.ttl)) { + val data = getValue(cacheKey, valueProvider) ?: return null + cache[cacheKey] = CacheData(data) + return data + } else { + return cacheItem.data as T? + } + } + + override fun put(key: CacheKey, value: T) { + val cacheKey = CacheKeySnapshot(key) + cache[cacheKey] = CacheData(value) + } + + override fun clear() { + jobs.onEach { it.value.cancel() } + jobs.clear() + cache.clear() + } + + override fun > invalidate(type: KClass) { + jobs.iterator().forEach { entry -> + val key = entry.key + if (key.type == type) { + jobs.remove(key)?.cancel() + } + } + cache.iterator().forEach { entry -> + if (entry.key.type == type) { + entry.value.invalidate() + } + } + } + + override fun > invalidate(key: K) { + val cacheKey = CacheKeySnapshot(key) + jobs.remove(cacheKey)?.cancel() + cache[cacheKey]?.invalidate() + } + + override fun > remove(type: KClass) { + jobs.iterator().forEach { entry -> + val key = entry.key + if (key.type == type) { + jobs.remove(key)?.cancel() + } + } + cache.iterator().forEach { entry -> + if (entry.key.type == type) { + cache.remove(entry.key)?.invalidate() + } + } + } + + override fun > remove(key: K) { + val cacheKey = CacheKeySnapshot(key) + jobs.remove(cacheKey)?.cancel() + cache.remove(cacheKey) + } + + private suspend fun getValue( + cacheKey: CacheKeySnapshot, + valueProvider: suspend () -> T? + ): T? { + val job = jobs[cacheKey] + ?.let { deferredJob -> + if (deferredJob.isCancelled) { + jobs.remove(cacheKey)?.cancel() + null + } else { + deferredJob + } + } + ?: run { + if (cacheKey.key.immortal()) { + jobs.computeIfAbsent(cacheKey) { + GlobalScope.async { valueProvider() } + } + } else { + withContext(dispatcher) { + jobs.computeIfAbsent(cacheKey) { + async { valueProvider() } + } + } + } + } + job.invokeOnCompletion { jobs.remove(cacheKey)?.key } + return job.await() as? T + } + + private data class CacheData( + val data: Any?, + val createTime: Long = Clock.System.now().toEpochMilliseconds() + ) { + private var invalid: Boolean = false + + fun isValid(ttl: Long): Boolean = when { + invalid -> false + data == null -> false + ttl > 0 -> !isExpired(ttl) + ttl == 0L -> false + else -> true + } + + fun isExpired(ttl: Long): Boolean { + val now = Clock.System.now().toEpochMilliseconds() + return ttl > 0 && createTime + ttl <= now + } + + fun invalidate() { + invalid = true + } + } + + private data class CacheKeySnapshot( + val key: CacheKey<*>, + val type: KClass<*> = key::class + ) + + private inner class CacheStateImpl( + override val key: CacheKey, + private val valueProvider: suspend () -> T? + ) : CacheState { + + private val cacheKey = CacheKeySnapshot(key) + + override suspend fun last(): T? = cache[cacheKey]?.data as? T + override suspend fun get(): T? = get(key, valueProvider) + override suspend fun fresh(): T? { + cache[cacheKey]?.invalidate() + return get() + } + + override suspend fun changes(): Flow = flow { + cache[cacheKey]?.data?.let { it as? T }?.let { emit(it) } + var attempt = 0 + while (currentCoroutineContext().isActive) { + try { + val fresh = get() + if (fresh != null) { + emit(fresh) + } + delay(cacheKey.key.ttl) + attempt = 0 + } catch (e: Exception) { + attempt++ + when { + attempt >= exceptionRetryCount -> throw e + else -> Unit + } + } + } + }.distinctUntilChanged().retry { th -> + !th.isCancellationException().also { + delay(changesRetryInterval) + } + } + } + +} \ No newline at end of file diff --git a/template/shared/data/src/commonMain/kotlin/shared/data/datasource/keyvalue/SettingsKeyValueSource.kt b/template/shared/data/src/commonMain/kotlin/shared/data/datasource/keyvalue/SettingsKeyValueSource.kt index 99087270..a341085a 100644 --- a/template/shared/data/src/commonMain/kotlin/shared/data/datasource/keyvalue/SettingsKeyValueSource.kt +++ b/template/shared/data/src/commonMain/kotlin/shared/data/datasource/keyvalue/SettingsKeyValueSource.kt @@ -2,6 +2,7 @@ package shared.data.datasource.keyvalue +import co.touchlab.stately.collections.ConcurrentMutableMap import com.russhwolf.settings.Settings import shared.data.serialization.SerializationStrategy diff --git a/template/shared/data/src/jvmTest/kotlin/shared/data/datasource/cache/InMemoryCacheSourceTest.kt b/template/shared/data/src/jvmTest/kotlin/shared/data/datasource/cache/InMemoryCacheSourceTest.kt new file mode 100644 index 00000000..399fcd01 --- /dev/null +++ b/template/shared/data/src/jvmTest/kotlin/shared/data/datasource/cache/InMemoryCacheSourceTest.kt @@ -0,0 +1,80 @@ +package shared.data.datasource.cache + +import io.ktor.util.collections.ConcurrentSet +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.time.Duration.Companion.seconds + +class InMemoryCacheSourceTest { + + private val cache: CacheSource = InMemoryCacheSource() + + @Test + fun `make sure all cache actions are performed in not blocking way`() = runBlocking { + val iterations = 1000 + val cached = ConcurrentSet() + repeat(iterations) { iteration -> + GlobalScope.launch { + cache + .get(TestCacheKey(iteration)) { + delay(2.seconds) + iteration + } + ?.let(cached::add) + } + } + delay(3.seconds) + assertEquals(iterations, cached.size) + } + + @Test + fun `make sure same actions use the same cached value`() = runBlocking { + val key = UUIDCacheKey(Int.MAX_VALUE) + val iterations = 1000 + val cached = ConcurrentSet() + repeat(iterations) { + launch { + delay(300) + cache + .get(key) { + delay(1.seconds) + UUID.randomUUID() + } + ?.let(cached::add) + } + } + delay(2.seconds) + assertEquals(1, cached.size) + } + + @Test + fun `check cached state logic`() = runBlocking { + val key = UUIDCacheKey(Int.MAX_VALUE, ttl = 100) + val valueState = cache.getState(key) { UUID.randomUUID() } + val value1 = valueState.get() + val value1Last = valueState.last() + delay(100) + val value2 = valueState.get() + delay(100) + assertNotEquals(value1, value2) + assertEquals(value1, value1Last) + assertNotEquals(value2, valueState.get()) + } + + private data class TestCacheKey( + val id: Int, + override val ttl: Long = CacheKey.TTL_UNLIMITED + ) : CacheKey + + private data class UUIDCacheKey( + val id: Int, + override val ttl: Long = CacheKey.TTL_UNLIMITED + ) : CacheKey + +} \ No newline at end of file