From fd0d97242cfa4d94eb17c97428fde847e433f76a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 22 Apr 2023 11:30:43 +0200 Subject: [PATCH 1/8] perf(cache): Replace MemoryCache's semaphore by a mutex --- cache/src/commonMain/kotlin/MemoryCache.kt | 30 +++++++++++----------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/cache/src/commonMain/kotlin/MemoryCache.kt b/cache/src/commonMain/kotlin/MemoryCache.kt index eaaa4c6..e10edd1 100644 --- a/cache/src/commonMain/kotlin/MemoryCache.kt +++ b/cache/src/commonMain/kotlin/MemoryCache.kt @@ -2,8 +2,8 @@ package opensavvy.cache import kotlinx.coroutines.* import kotlinx.coroutines.flow.* -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import opensavvy.cache.MemoryCache.Companion.cachedInMemory import opensavvy.logger.Logger.Companion.trace import opensavvy.logger.loggerFor @@ -48,16 +48,16 @@ class MemoryCache( */ private val cache = HashMap?>>() - private val cacheLock = Semaphore(1) + private val cacheLock = Mutex() private val jobs = HashMap() - private val jobsLock = Semaphore(1) + private val jobsLock = Mutex() /** **UNSAFE**: only call when owning the [cacheLock] */ private fun getUnsafe(id: I) = cache.getOrPut(id) { MutableStateFlow(null) } override fun get(id: I): ProgressiveFlow = flow { - val cached = cacheLock.withPermit { getUnsafe(id) } + val cached = cacheLock.withLock("get($id)") { getUnsafe(id) } .onEach { out -> if (out == null) { // Now, someone should make a request to the previous layer. @@ -73,19 +73,19 @@ class MemoryCache( }.onEach { log.trace(it) { "Emit value for $id ->" } } private suspend fun attemptTakeResponsibilityPingUpstream(id: I) { - jobsLock.withPermit { + jobsLock.withLock("takeResponsibility($id)") { val job = jobs[id] if (job == null || !job.isActive) { // No one is currently making the request, I'm taking the responsibility to do it val childContext = currentCoroutineContext() + - CoroutineName("$this(for = $id)") + - this.job + CoroutineName("$this(for = $id)") + + this.job jobs[id] = CoroutineScope(childContext).launch { log.trace(id) { "Subscribing to the previous layer for" } - val state = cacheLock.withPermit { getUnsafe(id) } + val state = cacheLock.withLock("upstreamSubscription($id)") { getUnsafe(id) } upstream[id] .onEach { log.trace(it) { "Prev value for $id ->" } } @@ -109,13 +109,13 @@ class MemoryCache( override suspend fun update(values: Collection>) { log.trace(values) { "update" } - jobsLock.withPermit { + jobsLock.withLock("updateJobs($values)") { for ((id, _) in values) { jobs.remove(id)?.cancel("MemoryCache.expire(refs) was called") } } - cacheLock.withPermit { + cacheLock.withLock("updateCache($values)") { for ((id, value) in values) { getUnsafe(id).value = ProgressiveOutcome.Success(value) } @@ -127,13 +127,13 @@ class MemoryCache( override suspend fun expire(ids: Collection) { log.trace(ids) { "expire" } - jobsLock.withPermit { + jobsLock.withLock("expireJobs($ids)") { for (id in ids) { jobs.remove(id)?.cancel("MemoryCache.expire(refs) was called") } } - cacheLock.withPermit { + cacheLock.withLock("expireCache($ids)") { for (id in ids) { val cached = cache[id] @@ -156,12 +156,12 @@ class MemoryCache( override suspend fun expireAll() { log.trace { "expireAll" } - jobsLock.withPermit { + jobsLock.withLock("expireAllJobs()") { jobs.values.forEach { it.cancel("MemoryCache.expireAll() was called") } jobs.clear() } - cacheLock.withPermit { + cacheLock.withLock("expireAllCaches()") { val toRemove = ArrayList() for ((id, cached) in cache) { -- 2.51.2 From c3e5cce04051db8f48dff640459f1ec9aa02f26f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 22 Apr 2023 11:33:11 +0200 Subject: [PATCH 2/8] perf(cache): Replace ExpirationCache's semaphore by a mutex --- cache/src/commonMain/kotlin/ExpirationCache.kt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/cache/src/commonMain/kotlin/ExpirationCache.kt b/cache/src/commonMain/kotlin/ExpirationCache.kt index 3b9fc00..9b8f482 100644 --- a/cache/src/commonMain/kotlin/ExpirationCache.kt +++ b/cache/src/commonMain/kotlin/ExpirationCache.kt @@ -2,8 +2,8 @@ package opensavvy.cache import kotlinx.coroutines.* import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.datetime.Clock import kotlinx.datetime.Instant import opensavvy.cache.ExpirationCache.Companion.expireAfter @@ -46,14 +46,14 @@ class ExpirationCache( private val log = loggerFor(this) private val lastUpdate = HashMap() - private val lock = Semaphore(1) + private val lock = Mutex() init { expirationScope.launch(CoroutineName("$this")) { while (isActive) { delay(expireAfter) - lock.withPermit { + lock.withLock("checkExpiredValues()") { val now = Clock.System.now() val iterator = lastUpdate.iterator() while (iterator.hasNext()) { @@ -71,7 +71,7 @@ class ExpirationCache( } private suspend fun markAsUpdatedNow(id: I) { - lock.withPermit { + lock.withLock("markAsUpdatedNow($id)") { log.trace(id) { "Updated now:" } lastUpdate[id] = Clock.System.now() } @@ -92,14 +92,14 @@ class ExpirationCache( override suspend fun expire(ids: Collection) { for (ref in ids) - lock.withPermit { + lock.withLock("expire($ids)") { lastUpdate.remove(ref) } upstream.expire(ids) } override suspend fun expireAll() { - lock.withPermit { + lock.withLock("expireAll()") { lastUpdate.clear() } upstream.expireAll() -- 2.51.2 From 4d756c7c75cb44cc1ba78225ce3bdda4567c36bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 22 Apr 2023 11:47:13 +0200 Subject: [PATCH 3/8] refactor(cache): Moved the cache builders to the top-level --- .../src/commonMain/kotlin/ExpirationCache.kt | 21 +++++++++---------- cache/src/commonMain/kotlin/MemoryCache.kt | 13 ++++++++---- cache/src/commonTest/kotlin/CacheTest.kt | 2 -- .../kotlin/PassThroughContextTest.kt | 1 - 4 files changed, 19 insertions(+), 18 deletions(-) diff --git a/cache/src/commonMain/kotlin/ExpirationCache.kt b/cache/src/commonMain/kotlin/ExpirationCache.kt index 9b8f482..16c3274 100644 --- a/cache/src/commonMain/kotlin/ExpirationCache.kt +++ b/cache/src/commonMain/kotlin/ExpirationCache.kt @@ -6,7 +6,6 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.datetime.Clock import kotlinx.datetime.Instant -import opensavvy.cache.ExpirationCache.Companion.expireAfter import opensavvy.logger.Logger.Companion.trace import opensavvy.logger.loggerFor import opensavvy.progress.done @@ -18,7 +17,7 @@ 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][ExpirationCache.Companion.expireAfter]: + * To add an [ExpirationCache] to a previous layer, use [Cache.expireAfter][Cache.expireAfter]: * ```kotlin * val cache = Cache.Default * .expireAfter(5.minutes, Job()) @@ -105,13 +104,13 @@ class ExpirationCache( upstream.expireAll() } - companion object { - /** - * Factory function to easily add a [ExpirationCache] layer to an existing cache chain. - * - * @see ExpirationCache - */ - fun Cache.expireAfter(duration: Duration, scope: CoroutineScope) = - ExpirationCache(this, duration, scope) - } + companion object } + +/** + * Factory function to easily add a [ExpirationCache] layer to an existing cache chain. + * + * @see ExpirationCache + */ +fun Cache.expireAfter(duration: Duration, scope: CoroutineScope) = + ExpirationCache(this, duration, scope) diff --git a/cache/src/commonMain/kotlin/MemoryCache.kt b/cache/src/commonMain/kotlin/MemoryCache.kt index e10edd1..2d5454e 100644 --- a/cache/src/commonMain/kotlin/MemoryCache.kt +++ b/cache/src/commonMain/kotlin/MemoryCache.kt @@ -4,7 +4,6 @@ import kotlinx.coroutines.* import kotlinx.coroutines.flow.* import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import opensavvy.cache.MemoryCache.Companion.cachedInMemory import opensavvy.logger.Logger.Companion.trace import opensavvy.logger.loggerFor import opensavvy.state.coroutines.ProgressiveFlow @@ -182,7 +181,13 @@ class MemoryCache( upstream.expireAll() } - companion object { - fun Cache.cachedInMemory(job: Job) = MemoryCache(this, job) - } + companion object } + +/** + * Creates a new cache layer which stores the last queried value for each identifier, and joins requests such that + * multiple subscribers to the same value only start a single request. + * + * For more information, see [MemoryCache]. + */ +fun Cache.cachedInMemory(job: Job) = MemoryCache(this, job) diff --git a/cache/src/commonTest/kotlin/CacheTest.kt b/cache/src/commonTest/kotlin/CacheTest.kt index 800a081..020efd0 100644 --- a/cache/src/commonTest/kotlin/CacheTest.kt +++ b/cache/src/commonTest/kotlin/CacheTest.kt @@ -5,8 +5,6 @@ import kotlinx.coroutines.* import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.test.runTest -import opensavvy.cache.ExpirationCache.Companion.expireAfter -import opensavvy.cache.MemoryCache.Companion.cachedInMemory import opensavvy.logger.LogLevel import opensavvy.logger.Logger.Companion.debug import opensavvy.logger.Logger.Companion.info diff --git a/cache/src/commonTest/kotlin/PassThroughContextTest.kt b/cache/src/commonTest/kotlin/PassThroughContextTest.kt index cee4c65..c97b9ba 100644 --- a/cache/src/commonTest/kotlin/PassThroughContextTest.kt +++ b/cache/src/commonTest/kotlin/PassThroughContextTest.kt @@ -2,7 +2,6 @@ package opensavvy.cache import kotlinx.coroutines.* import kotlinx.coroutines.test.runTest -import opensavvy.cache.MemoryCache.Companion.cachedInMemory import opensavvy.state.failure.Failure import opensavvy.state.outcome.Outcome import kotlin.coroutines.AbstractCoroutineContextElement -- 2.51.2 From 43fc709b63f726f70893d597391e5c7ac1bd380a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 22 Apr 2023 12:07:05 +0200 Subject: [PATCH 4/8] perf(cache): Do not create a wrapper lambda when instantiating a CacheAdapter --- cache/src/commonMain/kotlin/CacheAdapter.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cache/src/commonMain/kotlin/CacheAdapter.kt b/cache/src/commonMain/kotlin/CacheAdapter.kt index b73ef95..eb1380b 100644 --- a/cache/src/commonMain/kotlin/CacheAdapter.kt +++ b/cache/src/commonMain/kotlin/CacheAdapter.kt @@ -38,4 +38,4 @@ class CacheAdapter( * See [CacheAdapter]. */ fun cache(transform: suspend (I) -> Outcome) = - CacheAdapter { transform(it) } + CacheAdapter(transform) -- 2.51.2 From a4f6c33f16e52cecd420a302867628670f821029 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 22 Apr 2023 12:10:10 +0200 Subject: [PATCH 5/8] refactor(cache): CacheAdapter.query is now private --- cache/src/commonMain/kotlin/CacheAdapter.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cache/src/commonMain/kotlin/CacheAdapter.kt b/cache/src/commonMain/kotlin/CacheAdapter.kt index eb1380b..b9c20dd 100644 --- a/cache/src/commonMain/kotlin/CacheAdapter.kt +++ b/cache/src/commonMain/kotlin/CacheAdapter.kt @@ -12,7 +12,7 @@ import opensavvy.state.outcome.Outcome * and the underlying network APIs. */ class CacheAdapter( - val query: suspend (I) -> Outcome, + private val query: suspend (I) -> Outcome, ) : Cache { override fun get(id: I): ProgressiveFlow = captureProgress { query(id) } -- 2.51.2 From f969df0e5bce960b70edb435c1a45e146682aeec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 22 Apr 2023 12:22:33 +0200 Subject: [PATCH 6/8] refactor(cache): Remove the MemoryCache example, which doesn't work anymore --- cache/src/commonMain/kotlin/MemoryCache.kt | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/cache/src/commonMain/kotlin/MemoryCache.kt b/cache/src/commonMain/kotlin/MemoryCache.kt index 2d5454e..6b207f9 100644 --- a/cache/src/commonMain/kotlin/MemoryCache.kt +++ b/cache/src/commonMain/kotlin/MemoryCache.kt @@ -21,12 +21,7 @@ import opensavvy.state.progressive.copy * This implementation never frees the cache or invalidates elements inside it. * To free memory, add a subsequent layer responsible for it (e.g. [ExpirationCache]). * - * Use the [cachedInMemory] factory for easy cache chaining: - * ```kotlin - * val cache = Cache.Default() - * .cachedInMemory() - * .expireAfter(2.minutes) - * ``` + * Use the [cachedInMemory] factory for easy cache chaining. */ class MemoryCache( private val upstream: Cache, -- 2.51.2 From 575462f50e3e1ca985830822d0af8134c4b3460f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 22 Apr 2023 15:16:55 +0200 Subject: [PATCH 7/8] feat(cache): Contextual caches --- cache/src/commonMain/kotlin/MemoryCache.kt | 13 ++ .../ContextualBatchingCacheAdapter.kt | 59 ++++++ .../kotlin/contextual/ContextualCache.kt | 100 ++++++++++ .../contextual/ContextualCacheAdapter.kt | 44 +++++ .../contextual/ContextualExpirationCache.kt | 42 +++++ .../contextual/ContextualMemoryCache.kt | 37 ++++ .../kotlin/contextual/ContextualWrapper.kt | 34 ++++ .../commonTest/kotlin/ContextualCacheTest.kt | 176 ++++++++++++++++++ 8 files changed, 505 insertions(+) create mode 100644 cache/src/commonMain/kotlin/contextual/ContextualBatchingCacheAdapter.kt create mode 100644 cache/src/commonMain/kotlin/contextual/ContextualCache.kt create mode 100644 cache/src/commonMain/kotlin/contextual/ContextualCacheAdapter.kt create mode 100644 cache/src/commonMain/kotlin/contextual/ContextualExpirationCache.kt create mode 100644 cache/src/commonMain/kotlin/contextual/ContextualMemoryCache.kt create mode 100644 cache/src/commonMain/kotlin/contextual/ContextualWrapper.kt create mode 100644 cache/src/commonTest/kotlin/ContextualCacheTest.kt diff --git a/cache/src/commonMain/kotlin/MemoryCache.kt b/cache/src/commonMain/kotlin/MemoryCache.kt index 6b207f9..3660191 100644 --- a/cache/src/commonMain/kotlin/MemoryCache.kt +++ b/cache/src/commonMain/kotlin/MemoryCache.kt @@ -176,6 +176,19 @@ class MemoryCache( upstream.expireAll() } + /** + * Goes through the entire cache and expires all values for which [predicate] returns `true`. + */ + internal suspend fun expireIf(predicate: (I) -> Boolean) { + log.trace { "expireIf" } + + val targets = cacheLock.withLock("expireIf($predicate)") { + cache.keys.filter(predicate) + } + + expire(targets) + } + companion object } diff --git a/cache/src/commonMain/kotlin/contextual/ContextualBatchingCacheAdapter.kt b/cache/src/commonMain/kotlin/contextual/ContextualBatchingCacheAdapter.kt new file mode 100644 index 0000000..d421567 --- /dev/null +++ b/cache/src/commonMain/kotlin/contextual/ContextualBatchingCacheAdapter.kt @@ -0,0 +1,59 @@ +package opensavvy.cache.contextual + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import opensavvy.cache.BatchingCacheAdapter +import opensavvy.state.coroutines.ProgressiveFlow +import opensavvy.state.failure.Failure +import opensavvy.state.progressive.ProgressiveOutcome + +class ContextualBatchingCacheAdapter( + scope: CoroutineScope, + workers: Int, + queryBatch: (Set>) -> Flow>>, +) : ContextualCache { + + private val batching = BatchingCacheAdapter( + scope, + workers, + ) { + queryBatch(it) + .map { (id, context, value) -> id to context to value } + } + + override fun get(id: I, context: C): ProgressiveFlow = + batching[id to context] + + override suspend fun update(values: Collection>) { + // This cache layer has no state, nothing to do + } + + override suspend fun expire(ids: Collection) { + // This cache layer has no state, nothing to do + } + + override suspend fun expireContextual(ids: Collection>) { + // This cache layer has no state, nothing to do + } + + override suspend fun expireAll() { + // This cache layer has no state, nothing to do + } + +} + +fun batchingCache( + scope: CoroutineScope, + workers: Int = 1, + transform: suspend FlowCollector>>.(Set>) -> Unit, +) = ContextualBatchingCacheAdapter( + scope, + workers, +) { + flow { + transform(it) + } +} diff --git a/cache/src/commonMain/kotlin/contextual/ContextualCache.kt b/cache/src/commonMain/kotlin/contextual/ContextualCache.kt new file mode 100644 index 0000000..2f9ad94 --- /dev/null +++ b/cache/src/commonMain/kotlin/contextual/ContextualCache.kt @@ -0,0 +1,100 @@ +package opensavvy.cache.contextual + +import kotlinx.coroutines.flow.Flow +import opensavvy.cache.Cache +import opensavvy.state.coroutines.ProgressiveFlow +import opensavvy.state.failure.Failure + +/** + * Stores information temporarily to avoid unneeded network requests. + * + * Unlike [Cache], a contextual cache stores multiple values per identifier. + * The context decides which value is visible. + * Here are a few examples of context usage: + * - authentication information (different users see different values) + * - paging information (different values are returned depending on the requested page) + * + * The main advantage of using this interface rather than using a compound key in [Cache] is that it is possible to + * expire a value for all contexts. + * + * In all other regards (cache states, cache chaining…), this interface is identical to [Cache]. + * [Cache] can be seen as a specialization of this interface for the case where the context is [Unit]. + * + * @param I The identifier used to request from the cache. + * @param C The context which differentiates between cache results. + * @param F The possible failures when requesting the cache. + * @param T The possible successful value when requesting the cache. + */ +interface ContextualCache { + + /** + * Gets the value associated with an [id] and a [context] in this cache. + * + * This function returns a [Flow] instance synchronously: it is safe to call in synchronous-only areas of the + * program, such as inside the body of a UI component. You can then subscribe to the [Flow] to access the actual + * values. + */ + operator fun get(id: I, context: C): ProgressiveFlow + + /** + * Forces the cache to accept [value] as a more recent value for the given [id] and [context] than whatever it + * was previously storing. + * + * All layers are updated. + */ + suspend fun update(id: I, context: C, value: T) { + update(listOf(Triple(id, context, value))) + } + + /** + * Forces the cache to accept the given [values] as more recent for their associated identifier than whatever + * was previously stored. + * + * All layers are updated. + */ + suspend fun update(values: Collection>) + + /** + * Tells the cache that the values it stores for the given [id] are out of date, no matter the context, + * and should be queried again the next time they are requested. + * + * All layers are updated. + */ + suspend fun expire(id: I) { + expire(listOf(id)) + } + + /** + * Tells the cache that the value it stores for the given [id] and [context] is out of date, + * and should be queried again the next time they are requested. + * + * All layers are updated. + */ + suspend fun expire(id: I, context: C) { + expireContextual(listOf(id to context)) + } + + /** + * Tells the cache that the values it stores for the given [ids] are out of date, no matter the context, + * and should be queried again the next time they are requested. + * + * All layers are updated. + */ + suspend fun expire(ids: Collection) + + /** + * Tells the cache that the values it stores for the given [ids] are out of date, + * and should be queried again the next time they are requested. + * + * All layers are updated. + */ + suspend fun expireContextual(ids: Collection>) + + /** + * Tells the cache that all values are out of date, and should be queried again the next time they are requested. + * + * All layers are updated. + */ + suspend fun expireAll() + +} diff --git a/cache/src/commonMain/kotlin/contextual/ContextualCacheAdapter.kt b/cache/src/commonMain/kotlin/contextual/ContextualCacheAdapter.kt new file mode 100644 index 0000000..000a754 --- /dev/null +++ b/cache/src/commonMain/kotlin/contextual/ContextualCacheAdapter.kt @@ -0,0 +1,44 @@ +package opensavvy.cache.contextual + +import opensavvy.state.coroutines.ProgressiveFlow +import opensavvy.state.coroutines.captureProgress +import opensavvy.state.failure.Failure +import opensavvy.state.outcome.Outcome + +/** + * Cache implementation aimed to be the first link in a cache chain. + * + * This is not a valid implementation of a cache (it doesn't do any caching), and only serves as a link + * between caches and the underlying network APIs. + */ +class ContextualCacheAdapter( + private val query: suspend (I, C) -> Outcome, +) : ContextualCache { + override fun get(id: I, context: C): ProgressiveFlow = captureProgress { query(id, context) } + + override suspend fun update(values: Collection>) { + // This cache layer has no state, nothing to do + } + + override suspend fun expire(ids: Collection) { + // This cache layer has no state, nothing to do + } + + override suspend fun expireContextual(ids: Collection>) { + // This cache layer has no state, nothing to do + } + + override suspend fun expireAll() { + // This cache layer has no state, nothing to do + } + + companion object +} + +/** + * Creates a cache layer that intercepts requests. + * + * See [ContextualCacheAdapter]. + */ +fun cache(transform: suspend (I, C) -> Outcome): ContextualCache = + ContextualCacheAdapter(transform) diff --git a/cache/src/commonMain/kotlin/contextual/ContextualExpirationCache.kt b/cache/src/commonMain/kotlin/contextual/ContextualExpirationCache.kt new file mode 100644 index 0000000..927d089 --- /dev/null +++ b/cache/src/commonMain/kotlin/contextual/ContextualExpirationCache.kt @@ -0,0 +1,42 @@ +package opensavvy.cache.contextual + +import kotlinx.coroutines.CoroutineScope +import opensavvy.cache.expireAfter +import opensavvy.state.coroutines.ProgressiveFlow +import opensavvy.state.failure.Failure +import kotlin.time.Duration + +class ContextualExpirationCache( + private val upstream: ContextualCache, + duration: Duration, + scope: CoroutineScope, +) : ContextualCache { + + private val cache = ContextualWrapper(upstream) + .expireAfter(duration, scope) + + override fun get(id: I, context: C): ProgressiveFlow = + cache[id to context] + + override suspend fun update(values: Collection>) = + cache.update(values.map { (id, context, value) -> id to context to value }) + + override suspend fun expire(ids: Collection) { + // ExpirationCache doesn't store data, so we can directly expire the upstream + upstream.expire(ids) + } + + override suspend fun expireContextual(ids: Collection>) { + // ExpirationCache doesn't store data, so we can directly expire the upstream + upstream.expireContextual(ids) + } + + override suspend fun expireAll() { + // ExpirationCache doesn't store data, so we can directly expire the upstream + upstream.expireAll() + } + +} + +fun ContextualCache.expireAfter(duration: Duration, scope: CoroutineScope) = + ContextualExpirationCache(this, duration, scope) diff --git a/cache/src/commonMain/kotlin/contextual/ContextualMemoryCache.kt b/cache/src/commonMain/kotlin/contextual/ContextualMemoryCache.kt new file mode 100644 index 0000000..4905c51 --- /dev/null +++ b/cache/src/commonMain/kotlin/contextual/ContextualMemoryCache.kt @@ -0,0 +1,37 @@ +package opensavvy.cache.contextual + +import kotlinx.coroutines.Job +import opensavvy.cache.cachedInMemory +import opensavvy.state.coroutines.ProgressiveFlow +import opensavvy.state.failure.Failure + +class ContextualMemoryCache( + upstream: ContextualCache, + job: Job, +) : ContextualCache { + + private val cache = ContextualWrapper(upstream) + .cachedInMemory(job) + + override fun get(id: I, context: C): ProgressiveFlow = + cache[id to context] + + override suspend fun update(values: Collection>) = + cache.update(values.map { (id, context, value) -> id to context to value }) + + override suspend fun expire(ids: Collection) { + val filterIds = ids.toSet() + + cache.expireIf { (id, _) -> id in filterIds } + } + + override suspend fun expireContextual(ids: Collection>) = + cache.expire(ids) + + override suspend fun expireAll() = + cache.expireAll() + +} + +fun ContextualCache.cachedInMemory(job: Job) = + ContextualMemoryCache(this, job) diff --git a/cache/src/commonMain/kotlin/contextual/ContextualWrapper.kt b/cache/src/commonMain/kotlin/contextual/ContextualWrapper.kt new file mode 100644 index 0000000..62c1eb8 --- /dev/null +++ b/cache/src/commonMain/kotlin/contextual/ContextualWrapper.kt @@ -0,0 +1,34 @@ +package opensavvy.cache.contextual + +import opensavvy.cache.Cache +import opensavvy.state.coroutines.ProgressiveFlow +import opensavvy.state.failure.Failure + +/** + * Implementation of [Cache] for a [ContextualCache]. + */ +internal class ContextualWrapper( + private val upstream: ContextualCache, +) : Cache, F, T> { + + override fun get(id: Pair): ProgressiveFlow { + val (ref, context) = id + return upstream[ref, context] + } + + override suspend fun update(values: Collection, T>>) { + upstream.update(values.map { + val (identifier, value) = it + val (id, context) = identifier + Triple(id, context, value) + }) + } + + override suspend fun expire(ids: Collection>) { + upstream.expireContextual(ids) + } + + override suspend fun expireAll() { + upstream.expireAll() + } +} diff --git a/cache/src/commonTest/kotlin/ContextualCacheTest.kt b/cache/src/commonTest/kotlin/ContextualCacheTest.kt new file mode 100644 index 0000000..5b02c68 --- /dev/null +++ b/cache/src/commonTest/kotlin/ContextualCacheTest.kt @@ -0,0 +1,176 @@ +package opensavvy.cache + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.job +import kotlinx.coroutines.test.runTest +import opensavvy.cache.contextual.batchingCache +import opensavvy.cache.contextual.cache +import opensavvy.cache.contextual.cachedInMemory +import opensavvy.cache.contextual.expireAfter +import opensavvy.state.arrow.out +import opensavvy.state.coroutines.now +import opensavvy.state.failure.Failure +import opensavvy.state.outcome.success +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.minutes + +@OptIn(ExperimentalCoroutinesApi::class) +class ContextualCacheTest { + + private val data = generateSequence(0) { it + 1 } + + private data class Identifier( + val even: Boolean, + val odd: Boolean, + ) + + private data class Context( + val startAt: Int, + val limit: Int, + ) + + private fun createCache() = cache> { id, context -> + out { + data + .drop(context.startAt) + .filter { id.even || it % 2 != 0 } + .filter { id.odd || it % 2 != 1 } + .take(context.limit) + .toList() + } + } + + @Test + fun read() = runTest { + val cache = createCache() + .cachedInMemory(backgroundScope.coroutineContext.job) + .expireAfter(2.minutes, backgroundScope) + + val expected = (0 until 100).toList().success() + val actual = cache[Identifier(even = true, odd = true), Context(0, 100)].now() + + assertEquals(expected, actual) + } + + @Test + fun expire() = runTest { + val cache = createCache() + .cachedInMemory(backgroundScope.coroutineContext.job) + .expireAfter(2.minutes, backgroundScope) + + // Update with invalid values, so we can notice whether they are expired+queried or not + cache.update(Identifier(even = true, odd = true), Context(0, 100), (0 until 10).toList()) + cache.update(Identifier(even = true, odd = true), Context(10, 100), (0 until 10).toList()) + cache.update(Identifier(even = true, odd = false), Context(0, 100), (0 until 10).toList()) + + run { + val expected = (0 until 10).toList().success() + val actual = cache[Identifier(even = true, odd = true), Context(0, 100)].now() + assertEquals(expected, actual) + } + + run { + val expected = (0 until 10).toList().success() + val actual = cache[Identifier(even = true, odd = true), Context(10, 100)].now() + assertEquals(expected, actual) + } + + run { + val expected = (0 until 10).toList().success() + val actual = cache[Identifier(even = true, odd = false), Context(0, 100)].now() + assertEquals(expected, actual) + } + + // The first value should be updated, but the second one should be unchanged + cache.expire(Identifier(even = true, odd = true), Context(0, 100)) + + run { + val expected = (0 until 100).toList().success() + val actual = cache[Identifier(even = true, odd = true), Context(0, 100)].now() + assertEquals(expected, actual) + } + + run { + val expected = (0 until 10).toList().success() + val actual = cache[Identifier(even = true, odd = true), Context(10, 100)].now() + assertEquals(expected, actual) + } + + run { + val expected = (0 until 10).toList().success() + val actual = cache[Identifier(even = true, odd = false), Context(0, 100)].now() + assertEquals(expected, actual) + } + + // The first and second values should be updated + cache.expire(Identifier(even = true, odd = true)) + + run { + val expected = (0 until 100).toList().success() + val actual = cache[Identifier(even = true, odd = true), Context(0, 100)].now() + assertEquals(expected, actual) + } + + run { + val expected = (10 until 110).toList().success() + val actual = cache[Identifier(even = true, odd = true), Context(10, 100)].now() + assertEquals(expected, actual) + } + + run { + val expected = (0 until 10).toList().success() + val actual = cache[Identifier(even = true, odd = false), Context(0, 100)].now() + assertEquals(expected, actual) + } + + // The last value should be updated + cache.expireAll() + + run { + val expected = (0 until 100).toList().success() + val actual = cache[Identifier(even = true, odd = true), Context(0, 100)].now() + assertEquals(expected, actual) + } + + run { + val expected = (10 until 110).toList().success() + val actual = cache[Identifier(even = true, odd = true), Context(10, 100)].now() + assertEquals(expected, actual) + } + + run { + val expected = (0 until 200 step 2).toList().success() + val actual = cache[Identifier(even = true, odd = false), Context(0, 100)].now() + assertEquals(expected, actual) + } + } + + @Test + fun batching() = runTest { + val initial = createCache() + + @Suppress("RemoveExplicitTypeArguments") // IDEA is wrong, they are necessary + val cache = batchingCache>(backgroundScope) { request -> + for ((id, context) in request) { + emitAll( + initial[id, context] + .map { Triple(id, context, it) } + ) + } + } + + cache.expire(Identifier(even = true, odd = true)) + cache.expire(Identifier(even = true, odd = false), Context(0, 100)) + cache.expireAll() + cache.update(Identifier(even = false, odd = true), Context(0, 1), listOf(1)) + + run { + val expected = (0 until 200 step 2).toList().success() + val actual = cache[Identifier(even = true, odd = false), Context(0, 100)].now() + assertEquals(expected, actual) + } + } +} -- 2.51.2 From f93d3cedf96deb3ee955eda2fe8f3f8c11353314 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 22 Apr 2023 15:17:18 +0200 Subject: [PATCH 8/8] build(gradle): Name the convention builds (faster caching) --- gradle/conventions/settings.gradle.kts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/gradle/conventions/settings.gradle.kts b/gradle/conventions/settings.gradle.kts index 89fcd1d..b395bf5 100644 --- a/gradle/conventions/settings.gradle.kts +++ b/gradle/conventions/settings.gradle.kts @@ -1,3 +1,5 @@ +rootProject.name = "conventions" + dependencyResolutionManagement { repositories { mavenCentral() -- 2.51.2