diff --git a/backbone/src/commonTest/kotlin/opensavvy.backbone/cache/MemoryCacheTest.kt b/backbone/src/commonTest/kotlin/opensavvy.backbone/cache/MemoryCacheTest.kt index fe91559..6d7f600 100644 --- a/backbone/src/commonTest/kotlin/opensavvy.backbone/cache/MemoryCacheTest.kt +++ b/backbone/src/commonTest/kotlin/opensavvy.backbone/cache/MemoryCacheTest.kt @@ -41,9 +41,8 @@ class MemoryCacheTest { } } - @Test - fun withoutCache() = runTest { - val backbone = AbsoluteIntBackbone(Cache.Default()) + private suspend fun testCache(cache: Cache) { + val backbone = AbsoluteIntBackbone(cache) val zero = backbone.convert(0) val one = backbone.convert(1) @@ -54,22 +53,17 @@ class MemoryCacheTest { assertEquals(1u, minus.requestValue()) } + @Test + fun withoutCache() = runTest { + testCache(Cache.Default()) + } + @Test fun infiniteMemoryCache() = runTest { val job = Job() - val cache = Cache.Default() - .cachedInMemory(coroutineContext + job) - - val backbone = AbsoluteIntBackbone(cache) - - val zero = backbone.convert(0) - val one = backbone.convert(1) - val minus = backbone.convert(-1) - - assertEquals(0u, zero.requestValue()) - assertEquals(1u, one.requestValue()) - assertEquals(1u, minus.requestValue()) + testCache(Cache.Default() + .cachedInMemory(coroutineContext + job)) job.cancel() } @@ -78,18 +72,8 @@ class MemoryCacheTest { fun expiringDefaultCache() = runTest { val job = Job() - val cache = Cache.Default() - .expireAfter(1.minutes, coroutineContext + job) // This is useless, since the previous layer is a Default - - val backbone = AbsoluteIntBackbone(cache) - - val zero = backbone.convert(0) - val one = backbone.convert(1) - val minus = backbone.convert(-1) - - assertEquals(0u, zero.requestValue()) - assertEquals(1u, one.requestValue()) - assertEquals(1u, minus.requestValue()) + testCache(Cache.Default() + .expireAfter(1.minutes, coroutineContext + job)) job.cancel() } @@ -98,19 +82,9 @@ class MemoryCacheTest { fun expiringMemoryCache() = runTest { val job = Job() - val cache = Cache.Default() - .cachedInMemory(coroutineContext + job) - .expireAfter(1.minutes, coroutineContext + job) - - val backbone = AbsoluteIntBackbone(cache) - - val zero = backbone.convert(0) - val one = backbone.convert(1) - val minus = backbone.convert(-1) - - assertEquals(0u, zero.requestValue()) - assertEquals(1u, one.requestValue()) - assertEquals(1u, minus.requestValue()) + testCache(Cache.Default() + .cachedInMemory(coroutineContext + job) + .expireAfter(1.minutes, coroutineContext + job)) job.cancel() } -- 2.51.2 From f28c88da75fb0380753115ae6d95910e495d6f78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 3 Jul 2022 14:16:29 +0200 Subject: [PATCH 02/14] fix(backbone): Ref.expire should completely expire the value, not just in the first cache layer --- backbone/src/commonMain/kotlin/opensavvy/backbone/Ref.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backbone/src/commonMain/kotlin/opensavvy/backbone/Ref.kt b/backbone/src/commonMain/kotlin/opensavvy/backbone/Ref.kt index 0687a2f..87d275a 100644 --- a/backbone/src/commonMain/kotlin/opensavvy/backbone/Ref.kt +++ b/backbone/src/commonMain/kotlin/opensavvy/backbone/Ref.kt @@ -48,7 +48,7 @@ interface Ref { * The next time [request] is called, a new request will be started. */ suspend fun Ref.expire() { - backbone.cache.expire(this) + backbone.cache.expireAllRecursively(listOf(this)) } /** -- 2.51.2 From b8d3a6f56b1f9d5be5f681accd3f65d8210eef6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 3 Jul 2022 14:16:48 +0200 Subject: [PATCH 03/14] tests(backbone): Test Cache.expire and Cache.update --- .../cache/MemoryCacheTest.kt | 48 +++++++++++++++---- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/backbone/src/commonTest/kotlin/opensavvy.backbone/cache/MemoryCacheTest.kt b/backbone/src/commonTest/kotlin/opensavvy.backbone/cache/MemoryCacheTest.kt index 6d7f600..244ccb4 100644 --- a/backbone/src/commonTest/kotlin/opensavvy.backbone/cache/MemoryCacheTest.kt +++ b/backbone/src/commonTest/kotlin/opensavvy.backbone/cache/MemoryCacheTest.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.test.runTest import opensavvy.backbone.* +import opensavvy.backbone.Ref.Companion.expire import opensavvy.backbone.Ref.Companion.requestValue import opensavvy.backbone.cache.ExpirationCache.Companion.expireAfter import opensavvy.backbone.cache.MemoryCache.Companion.cachedInMemory @@ -41,9 +42,14 @@ class MemoryCacheTest { } } + /** + * Tests that running a request through the cache actually sends it to the backbone + */ private suspend fun testCache(cache: Cache) { + cache.expireAllRecursively() val backbone = AbsoluteIntBackbone(cache) + println("Normal access via directRequest...") val zero = backbone.convert(0) val one = backbone.convert(1) val minus = backbone.convert(-1) @@ -53,17 +59,40 @@ class MemoryCacheTest { assertEquals(1u, minus.requestValue()) } + /** + * Tests updating and expiring a value + * + * This test only applies to stateful cache layers (e.g. MemoryCache) + */ + private suspend fun testUpdateExpiration(cache: Cache) { + cache.expireAllRecursively() + val backbone = AbsoluteIntBackbone(cache) + + println("\nForcing a different value") + val zero = backbone.convert(0) + backbone.cache.update(zero, 5u) + assertEquals(5u, zero.requestValue()) + + println("\nExpiring the value re-downloads it and replaces our invalid value") + zero.expire() + assertEquals(0u, zero.requestValue()) + } + @Test fun withoutCache() = runTest { - testCache(Cache.Default()) + val cache = Cache.Default() + + testCache(cache) } @Test fun infiniteMemoryCache() = runTest { val job = Job() + val cache = Cache.Default() + .cachedInMemory(coroutineContext + job) - testCache(Cache.Default() - .cachedInMemory(coroutineContext + job)) + testCache(cache) + testUpdateExpiration(cache) job.cancel() } @@ -71,9 +100,10 @@ class MemoryCacheTest { @Test fun expiringDefaultCache() = runTest { val job = Job() + val cache = Cache.Default() + .expireAfter(1.minutes, coroutineContext + job) - testCache(Cache.Default() - .expireAfter(1.minutes, coroutineContext + job)) + testCache(cache) job.cancel() } @@ -81,10 +111,12 @@ class MemoryCacheTest { @Test fun expiringMemoryCache() = runTest { val job = Job() + val cache = Cache.Default() + .cachedInMemory(coroutineContext + job) + .expireAfter(1.minutes, coroutineContext + job) - testCache(Cache.Default() - .cachedInMemory(coroutineContext + job) - .expireAfter(1.minutes, coroutineContext + job)) + testCache(cache) + testUpdateExpiration(cache) job.cancel() } -- 2.51.2 From e6153e969b730f589d283bff997cf3bdc02b0456 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 3 Jul 2022 18:01:02 +0200 Subject: [PATCH 04/14] style(backbone): Smaller Data.toString output --- backbone/src/commonMain/kotlin/opensavvy/backbone/Data.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backbone/src/commonMain/kotlin/opensavvy/backbone/Data.kt b/backbone/src/commonMain/kotlin/opensavvy/backbone/Data.kt index 127adb5..5378948 100644 --- a/backbone/src/commonMain/kotlin/opensavvy/backbone/Data.kt +++ b/backbone/src/commonMain/kotlin/opensavvy/backbone/Data.kt @@ -50,7 +50,7 @@ data class Data( val ref: Ref, ) { - override fun toString() = "Data($data is $status for $ref)" + override fun toString() = "$data is $status for $ref" /** * Whether a piece of [Data] is [Completed] or still [Loading]. -- 2.51.2 From 5b34b2db7011d6fd2b236a35bfd2cb70892e85e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 3 Jul 2022 18:03:10 +0200 Subject: [PATCH 05/14] fix(backbone): Actually expire the previous layer in ExpirationCache Previously, it removed the previous item but did not even mark it as expired, making the whole class useless --- .../kotlin/opensavvy/backbone/cache/ExpirationCache.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/backbone/src/commonMain/kotlin/opensavvy/backbone/cache/ExpirationCache.kt b/backbone/src/commonMain/kotlin/opensavvy/backbone/cache/ExpirationCache.kt index 55a546e..9400731 100644 --- a/backbone/src/commonMain/kotlin/opensavvy/backbone/cache/ExpirationCache.kt +++ b/backbone/src/commonMain/kotlin/opensavvy/backbone/cache/ExpirationCache.kt @@ -12,6 +12,7 @@ import opensavvy.backbone.Data import opensavvy.backbone.Ref import opensavvy.backbone.cache.ExpirationCache.Companion.expireAfter import opensavvy.logger.Logger.Companion.debug +import opensavvy.logger.Logger.Companion.trace import opensavvy.logger.loggerFor import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext @@ -62,10 +63,13 @@ class ExpirationCache( val now = Clock.System.now() val iterator= lastUpdate.iterator() while (iterator.hasNext()) { - val (_, instant) = iterator.next() + val (ref, instant) = iterator.next() - if (instant < now - expireAfter) + if (instant < now - expireAfter) { + log.trace(ref) { "Removed reference" } iterator.remove() + upstream.expire(ref) + } } } } -- 2.51.2 From 84fa6f2a954cf55f88c353d4496e1b264a8d3c71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 3 Jul 2022 18:05:03 +0200 Subject: [PATCH 06/14] refactor(backbone): ExpirationCache.updateAll is identical to the default implementation When the previous layer's updateAll is called, it will notify downstream layers of the update, which in turn will call 'markAsUpdatedNow': it's useless to call it in updateAll. ExpirationCache just has to forward updateAll to the previous layer, which is the default implementation. --- .../kotlin/opensavvy/backbone/cache/ExpirationCache.kt | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/backbone/src/commonMain/kotlin/opensavvy/backbone/cache/ExpirationCache.kt b/backbone/src/commonMain/kotlin/opensavvy/backbone/cache/ExpirationCache.kt index 9400731..2826727 100644 --- a/backbone/src/commonMain/kotlin/opensavvy/backbone/cache/ExpirationCache.kt +++ b/backbone/src/commonMain/kotlin/opensavvy/backbone/cache/ExpirationCache.kt @@ -78,6 +78,7 @@ class ExpirationCache( private suspend fun markAsUpdatedNow(ref: Ref) { lock.withPermit { + log.trace(ref) { "markAsUpdatedNow" } lastUpdate[ref] = Clock.System.now() } } @@ -85,14 +86,6 @@ class ExpirationCache( override fun get(ref: Ref): Flow> = upstream[ref] .onEach { markAsUpdatedNow(ref) } - override suspend fun updateAll(values: Iterable>) { - for (value in values) - markAsUpdatedNow(value.ref) - - // Ensure the previous layers are updated as well - super.updateAll(values) - } - override suspend fun expireAll(refs: Iterable>) { for (ref in refs) lock.withPermit { -- 2.51.2 From 5cdf594002643f3cc104493ce472b08e133a7081 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 3 Jul 2022 18:17:58 +0200 Subject: [PATCH 07/14] fix(backbone): Mark the inner entry has empty instead of setting it to null Setting it to null removes the reference to the inner MutableStateFlow. On the next 'get', a new one is instantiated. This means that all previous subscribers are now subscribed to a dead StateFlow, and will never receive updates again. Instead, this commit marks the data as empty instead (which forces the 'get' subscribers to start a new query). However, this means the cache memory is never cleaned (unbounded growth of the cache): to be fixed in a future commit. --- .../opensavvy/backbone/cache/MemoryCache.kt | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/backbone/src/commonMain/kotlin/opensavvy/backbone/cache/MemoryCache.kt b/backbone/src/commonMain/kotlin/opensavvy/backbone/cache/MemoryCache.kt index 2c910c6..18242eb 100644 --- a/backbone/src/commonMain/kotlin/opensavvy/backbone/cache/MemoryCache.kt +++ b/backbone/src/commonMain/kotlin/opensavvy/backbone/cache/MemoryCache.kt @@ -77,26 +77,31 @@ class MemoryCache( } override suspend fun expireAll(refs: Iterable>) { - cacheLock.withPermit { - for (ref in refs) - cache.remove(ref) - } + log.trace(refs) { "expireAll" } jobsLock.withPermit { for (ref in refs) { jobs.remove(ref)?.cancel("MemoryCache.expireAll(refs) was called") } } - } - override suspend fun expireAll() { cacheLock.withPermit { - cache.clear() + for (ref in refs) + cache[ref]?.value = ref.initialData } + } + + override suspend fun expireAll() { + log.trace { "expireAll" } jobsLock.withPermit { jobs.values.forEach { it.cancel("MemoryCache.expireAll() was called") } } + + cacheLock.withPermit { + for (ref in cache.keys) + cache[ref]?.value = ref.initialData + } } companion object { -- 2.51.2 From 6efd6b8d6b0bcf7c34d0b85d06ed842221a9420a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 3 Jul 2022 18:20:35 +0200 Subject: [PATCH 08/14] fix(backbone): Start a new request on any new value, not just the first time Previously, a new upstream request was fired everytime 'get' is called. Now, a new one is fired everytime the flow changes (e.g. new upstream value, call to 'expire' or 'update'), but ONLY if there are no on-going requests currently going on. --- .../opensavvy/backbone/cache/MemoryCache.kt | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/backbone/src/commonMain/kotlin/opensavvy/backbone/cache/MemoryCache.kt b/backbone/src/commonMain/kotlin/opensavvy/backbone/cache/MemoryCache.kt index 18242eb..d478621 100644 --- a/backbone/src/commonMain/kotlin/opensavvy/backbone/cache/MemoryCache.kt +++ b/backbone/src/commonMain/kotlin/opensavvy/backbone/cache/MemoryCache.kt @@ -48,26 +48,32 @@ class MemoryCache( private fun getUnsafe(ref: Ref) = cache.getOrPut(ref) { MutableStateFlow(ref.initialData) } override fun get(ref: Ref): Flow> = flow { + log.trace(ref) { "get called for" } + emit(ref.initialData) val cached = cacheLock.withPermit { getUnsafe(ref) } - jobsLock.withPermit { - jobs.getOrPut(ref) { - scope.launch(CoroutineName("MemoryCache for $ref")) { - log.trace { "Subscribing to previous layer for $ref" } + cached.collect { data -> + emit(data) + + jobsLock.withPermit { + jobs.getOrPut(ref) { + scope.launch(CoroutineName("MemoryCache for $ref")) { + log.trace { "Subscribing to previous layer for $ref" } - upstream[ref] - .collect { cached.value = it } + upstream[ref] + .collect { cached.value = it } + } } } } - - emitAll(cached) }.distinctUntilChanged() - .onEach { log.trace(it) { "Updated value" } } + .onEach { log.trace(it) { "new value emitted from 'get'" } } override suspend fun updateAll(values: Iterable>) { + log.trace(values) { "updateAll" } + cacheLock.withPermit { for (value in values) getUnsafe(value.ref).value = value -- 2.51.2 From 00325e2d5b0a53962f1658b3854424d9a74ec838 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 3 Jul 2022 18:30:50 +0200 Subject: [PATCH 09/14] fix(backbone): Start a new subscriber if the previous died --- .../opensavvy/backbone/cache/MemoryCache.kt | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/backbone/src/commonMain/kotlin/opensavvy/backbone/cache/MemoryCache.kt b/backbone/src/commonMain/kotlin/opensavvy/backbone/cache/MemoryCache.kt index d478621..a4bdce5 100644 --- a/backbone/src/commonMain/kotlin/opensavvy/backbone/cache/MemoryCache.kt +++ b/backbone/src/commonMain/kotlin/opensavvy/backbone/cache/MemoryCache.kt @@ -58,14 +58,32 @@ class MemoryCache( emit(data) jobsLock.withPermit { - jobs.getOrPut(ref) { - scope.launch(CoroutineName("MemoryCache for $ref")) { - log.trace { "Subscribing to previous layer for $ref" } + // A new event arrived + // Possible causes: + // - the previous layer was updated + // - 'update' or 'expire' were called + + // There are three possible cases: + // 1. We are not subscribed to the previous layer for this ref + // -> subscribe to it + // 2. We are subscribed to the previous layer for this ref + // -> nothing to do + // 3. We were previously subscribed, but the subscriber died + // -> subscribe to the ref (overwrite the dead subscriber) + + val job = jobs[ref] + if (job == null || !job.isActive) { + // job == null: case 1 + // job is not active: case 3 + // in both cases, a new job must be started + + jobs[ref] = scope.launch(CoroutineName("MemoryCache for $ref")) { + log.trace { "Subscribing to the previous layer for $ref" } upstream[ref] .collect { cached.value = it } } - } + } // else: case 2, nothing to do } } }.distinctUntilChanged() -- 2.51.2 From 8cd1059a76ff902536f0aa09fa27531f121f07a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 3 Jul 2022 18:32:04 +0200 Subject: [PATCH 10/14] tests: Cleaner logging for tests - Removed the thread and the coroutine name - Better alignment of the class name --- tester/src/jvmMain/resources/logback.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tester/src/jvmMain/resources/logback.xml b/tester/src/jvmMain/resources/logback.xml index 7821ac7..2f8b213 100644 --- a/tester/src/jvmMain/resources/logback.xml +++ b/tester/src/jvmMain/resources/logback.xml @@ -1,7 +1,7 @@ - %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + %d{HH:mm:ss.SSS} %-5level %-50logger{40}\t%msg%n -- 2.51.2 From 31672aed3abd43b22de8b2c3fab267f133637578 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 3 Jul 2022 18:32:45 +0200 Subject: [PATCH 11/14] tests(backbone): Test that ExpirationCache actually expires the cache --- .../cache/MemoryCacheTest.kt | 36 +++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/backbone/src/commonTest/kotlin/opensavvy.backbone/cache/MemoryCacheTest.kt b/backbone/src/commonTest/kotlin/opensavvy.backbone/cache/MemoryCacheTest.kt index 244ccb4..2cc9222 100644 --- a/backbone/src/commonTest/kotlin/opensavvy.backbone/cache/MemoryCacheTest.kt +++ b/backbone/src/commonTest/kotlin/opensavvy.backbone/cache/MemoryCacheTest.kt @@ -3,10 +3,15 @@ package opensavvy.backbone.cache import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.test.runTest import opensavvy.backbone.* +import opensavvy.backbone.Data.Companion.firstSuccessOrThrow +import opensavvy.backbone.Data.Companion.skipLoading import opensavvy.backbone.Ref.Companion.expire +import opensavvy.backbone.Ref.Companion.request import opensavvy.backbone.Ref.Companion.requestValue import opensavvy.backbone.cache.ExpirationCache.Companion.expireAfter import opensavvy.backbone.cache.MemoryCache.Companion.cachedInMemory @@ -14,13 +19,14 @@ import opensavvy.logger.Logger.Companion.info import opensavvy.logger.loggerFor import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes @OptIn(ExperimentalCoroutinesApi::class) class MemoryCacheTest { private data class AbsoluteIntRef(val value: Int, override val backbone: Backbone) : Ref { - override fun toString() = "AbsoluteIntRef($value)" + override fun toString() = "UIntOf($value)" } private class AbsoluteIntBackbone(override val cache: Cache) : Backbone { @@ -78,6 +84,31 @@ class MemoryCacheTest { assertEquals(0u, zero.requestValue()) } + /** + * Tests that the data is correctly expired + * + * This test only applies to layers which automatically expire data after some time (e.g. ExpirationCache) + */ + private suspend fun testAutoExpiration(cache: Cache) { + cache.expireAllRecursively() + val backbone = AbsoluteIntBackbone(cache) + + println("\nAdding 5u to the cache to make updates visible") + val zero = backbone.convert(0) + assertEquals(0u, zero.requestValue()) + backbone.cache.update( + zero, + 5u + ) // adding a weird value, so we can detect when it decides to automatically trigger the update + + println("Querying until the cache a non-5u value") + assertEquals(0u, zero.request() + .skipLoading() + .onEach { println("-> $it") } + .drop(1) // ignore the 5u + .firstSuccessOrThrow()) + } + @Test fun withoutCache() = runTest { val cache = Cache.Default() @@ -113,10 +144,11 @@ class MemoryCacheTest { val job = Job() val cache = Cache.Default() .cachedInMemory(coroutineContext + job) - .expireAfter(1.minutes, coroutineContext + job) + .expireAfter(300.milliseconds, coroutineContext + job) testCache(cache) testUpdateExpiration(cache) + testAutoExpiration(cache) job.cancel() } -- 2.51.2 From 06bbdf1444190bea806e13a920beefeb1354eb38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 3 Jul 2022 18:45:41 +0200 Subject: [PATCH 12/14] tests(backbone): Multiple subscribers only send a single request --- .../opensavvy.backbone/cache/CacheTracing.kt | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 backbone/src/commonTest/kotlin/opensavvy.backbone/cache/CacheTracing.kt diff --git a/backbone/src/commonTest/kotlin/opensavvy.backbone/cache/CacheTracing.kt b/backbone/src/commonTest/kotlin/opensavvy.backbone/cache/CacheTracing.kt new file mode 100644 index 0000000..1edb938 --- /dev/null +++ b/backbone/src/commonTest/kotlin/opensavvy.backbone/cache/CacheTracing.kt @@ -0,0 +1,66 @@ +package opensavvy.backbone.cache + +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.test.runTest +import opensavvy.backbone.* +import opensavvy.backbone.Ref.Companion.requestValue +import opensavvy.backbone.cache.MemoryCache.Companion.cachedInMemory +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class CacheTracing { + private data class TracingRef(val value: Int, override val backbone: Backbone) : Ref { + override fun toString() = "Int($value)" + } + + private class TracingBackbone(override val cache: Cache): Backbone { + var tracker = 0 + val lock = Semaphore(1) + + override fun directRequest(ref: Ref): Flow> = flow { + require(ref is TracingRef) { "This backbone only works with TracingRef, found $ref" } + + lock.withPermit { + tracker++ + } + + delay(10) // give time to the other coroutines to execute + emit(Data(Result.Success(ref.value), Data.Status.Completed, ref)) + } + + fun get(value: Int) = TracingRef(value, this) + } + + /** + * Two different subscribers on the same ref + * + * If we expire this ref, only one real request must be launched (not one each!) + */ + @Test + fun concurrentRequest() = runTest { + val job = Job() + + val backbone = TracingBackbone(Cache.Default().cachedInMemory(coroutineContext + job)) + val zero = backbone.get(0) + assertEquals(0, backbone.tracker) + + coroutineScope { + // Start 10 parallel requests + repeat(10) { + launch { + assertEquals(0, zero.requestValue()) + } + } + } + + // After the 10 requests have finished, check that only one real request was sent + assertEquals(1, backbone.tracker) + + job.cancel() + } +} -- 2.51.2 From e60148dcb582f6b80d26f8a01d76c5294df084de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 3 Jul 2022 18:49:21 +0200 Subject: [PATCH 13/14] tests(backbone): Expire does not start new requests when no one is subscribed --- .../opensavvy.backbone/cache/CacheTracing.kt | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/backbone/src/commonTest/kotlin/opensavvy.backbone/cache/CacheTracing.kt b/backbone/src/commonTest/kotlin/opensavvy.backbone/cache/CacheTracing.kt index 1edb938..45a3b45 100644 --- a/backbone/src/commonTest/kotlin/opensavvy.backbone/cache/CacheTracing.kt +++ b/backbone/src/commonTest/kotlin/opensavvy.backbone/cache/CacheTracing.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.test.runTest import opensavvy.backbone.* +import opensavvy.backbone.Ref.Companion.expire import opensavvy.backbone.Ref.Companion.requestValue import opensavvy.backbone.cache.MemoryCache.Companion.cachedInMemory import kotlin.test.Test @@ -63,4 +64,26 @@ class CacheTracing { job.cancel() } + + /** + * Expiring a value when no one is subscribed to it does not start a new request + */ + @Test + fun expireNoSubscribers() = runTest { + val job = Job() + + val backbone = TracingBackbone(Cache.Default().cachedInMemory(coroutineContext + job)) + val zero = backbone.get(0) + assertEquals(0, backbone.tracker) + + // Start one request to ensure everything is initialized correctly + assertEquals(0, zero.requestValue()) + assertEquals(1, backbone.tracker) + + // Expire the value, no new requests should be started + zero.expire() + assertEquals(1, backbone.tracker) + + job.cancel() + } } -- 2.51.2 From 420cfdac36f4cc7ee7b46f3dafccd3523c3cc480 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 3 Jul 2022 18:54:25 +0200 Subject: [PATCH 14/14] tests(backbone): Test progression calculation --- .../kotlin/opensavvy.backbone/DataTest.kt | 28 +++++++++++++++++++ .../kotlin/opensavvy.backbone/Test.kt | 13 --------- 2 files changed, 28 insertions(+), 13 deletions(-) create mode 100644 backbone/src/commonTest/kotlin/opensavvy.backbone/DataTest.kt delete mode 100644 backbone/src/commonTest/kotlin/opensavvy.backbone/Test.kt diff --git a/backbone/src/commonTest/kotlin/opensavvy.backbone/DataTest.kt b/backbone/src/commonTest/kotlin/opensavvy.backbone/DataTest.kt new file mode 100644 index 0000000..4b225a0 --- /dev/null +++ b/backbone/src/commonTest/kotlin/opensavvy.backbone/DataTest.kt @@ -0,0 +1,28 @@ +package opensavvy.backbone + +import kotlin.test.Test +import kotlin.test.assertEquals + +class DataTest { + + @Test + fun percent() { + val loadingNoInfo = Data.Status.Loading.Basic() + assertEquals(null, loadingNoInfo.progression) + assertEquals(null, loadingNoInfo.percent) + assertEquals("Loading.Basic", loadingNoInfo.toString()) + + val loadingStart = Data.Status.Loading.Basic(0f) + assertEquals(0f, loadingStart.progression) + assertEquals(0, loadingStart.percent) + + val loadingThird = Data.Status.Loading.Basic(0.33f) + assertEquals(0.33f, loadingThird.progression) + assertEquals(33, loadingThird.percent) + assertEquals("Loading.Basic(progression = 0.33)", loadingThird.toString()) + + val loadingDone = Data.Status.Loading.Basic(1.0f) + assertEquals(1.0f, loadingDone.progression) + assertEquals(100, loadingDone.percent) + } +} diff --git a/backbone/src/commonTest/kotlin/opensavvy.backbone/Test.kt b/backbone/src/commonTest/kotlin/opensavvy.backbone/Test.kt deleted file mode 100644 index cdb1747..0000000 --- a/backbone/src/commonTest/kotlin/opensavvy.backbone/Test.kt +++ /dev/null @@ -1,13 +0,0 @@ -package opensavvy.backbone - -import kotlin.test.Test -import kotlin.test.assertTrue - -class BackboneTest { - - @Test - fun test() { - assertTrue(true) - } - -}