From ab4d2e088be6707eb48bebdf79c92bbac0793a67 Mon Sep 17 00:00:00 2001 From: William Brawner Date: Thu, 7 Dec 2023 15:32:09 -0700 Subject: [PATCH] Add cacheOnly option to StoreReadRequest (#586) * Add cacheOnly option to StoreReadRequest Signed-off-by: William Brawner * Fix doc on StoreReadRequest.cacheOnly Signed-off-by: William Brawner * Hit disk caches for cacheOnly requests Signed-off-by: William Brawner * Rename cacheOnly to localOnly Signed-off-by: William Brawner * Send NoNewData and log warning for localOnly requests with no local data sources configured Signed-off-by: William Brawner --------- Signed-off-by: William Brawner Signed-off-by: William Brawner --- .../store/store5/StoreReadRequest.kt | 18 ++- .../store/store5/impl/RealStore.kt | 31 +++- .../store/store5/LocalOnlyTests.kt | 149 ++++++++++++++++++ 3 files changed, 194 insertions(+), 4 deletions(-) create mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/LocalOnlyTests.kt diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreReadRequest.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreReadRequest.kt index a10c3afcc..8cf581348 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreReadRequest.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreReadRequest.kt @@ -21,13 +21,14 @@ package org.mobilenativefoundation.store.store5 * @param skippedCaches List of cache types that should be skipped when retuning the response see [CacheType] * @param refresh If set to true [Store] will always get fresh value from fetcher while also * starting the stream from the local [com.dropbox.android.external.store4.impl.SourceOfTruth] and memory cache - * + * @param fetch If set to false, then fetcher will not be used */ data class StoreReadRequest private constructor( val key: Key, private val skippedCaches: Int, val refresh: Boolean = false, - val fallBackToSourceOfTruth: Boolean = false + val fallBackToSourceOfTruth: Boolean = false, + val fetch: Boolean = true ) { internal fun shouldSkipCache(type: CacheType) = skippedCaches.and(type.flag) != 0 @@ -57,7 +58,8 @@ data class StoreReadRequest private constructor( ) /** - * Create a [StoreReadRequest] which will return data from memory/disk caches + * Create a [StoreReadRequest] which will return data from memory/disk caches if present, + * otherwise will hit your fetcher (filling your caches). * @param refresh if true then return fetcher (new) data as well (updating your caches) */ fun cached(key: Key, refresh: Boolean) = StoreReadRequest( @@ -66,6 +68,16 @@ data class StoreReadRequest private constructor( refresh = refresh ) + /** + * Create a [StoreReadRequest] which will return data from memory/disk caches if present, + * otherwise will return [StoreReadResponse.NoNewData] + */ + fun localOnly(key: Key) = StoreReadRequest( + key = key, + skippedCaches = 0, + fetch = false + ) + /** * Create a [StoreReadRequest] which will return data from disk cache * @param refresh if true then return fetcher (new) data as well (updating your caches) diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStore.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStore.kt index 2a7c30140..878d22c72 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStore.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStore.kt @@ -15,6 +15,8 @@ */ package org.mobilenativefoundation.store.store5.impl +import co.touchlab.kermit.CommonWriter +import co.touchlab.kermit.Logger import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow @@ -89,6 +91,15 @@ internal class RealStore( // if we read a value from cache, dispatch it first emit(StoreReadResponse.Data(value = it, origin = StoreReadResponseOrigin.Cache)) } + + if (sourceOfTruth == null && !request.fetch) { + if (memCache == null) { + logger.w("Local-only request made with no cache or source of truth configured") + } + emit(StoreReadResponse.NoNewData(origin = StoreReadResponseOrigin.Cache)) + return@flow + } + val stream: Flow> = if (sourceOfTruth == null) { // piggypack only if not specified fresh data AND we emitted a value from the cache val piggybackOnly = !request.refresh && cachedToEmit != null @@ -99,8 +110,19 @@ internal class RealStore( networkLock = null, piggybackOnly = piggybackOnly ) as Flow> // when no source of truth Input == Output - } else { + } else if (request.fetch) { diskNetworkCombined(request, sourceOfTruth) + } else { + val diskLock = CompletableDeferred() + diskLock.complete(Unit) + sourceOfTruth.reader(request.key, diskLock).transform { response -> + val data = response.dataOrNull() + if (data == null || validator?.isValid(data) == false) { + emit(StoreReadResponse.NoNewData(origin = response.origin)) + } else { + emit(StoreReadResponse.Data(value = data, origin = response.origin)) + } + } } emitAll( stream.transform { output: StoreReadResponse -> @@ -312,4 +334,11 @@ internal class RealStore( sourceOfTruth?.reader(key, CompletableDeferred(Unit))?.map { it.dataOrNull() }?.first() private fun fromMemCache(key: Key) = memCache?.getIfPresent(key) + + companion object { + private val logger = Logger.apply { + setLogWriters(listOf(CommonWriter())) + setTag("Store") + } + } } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/LocalOnlyTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/LocalOnlyTests.kt new file mode 100644 index 000000000..b32044e65 --- /dev/null +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/LocalOnlyTests.kt @@ -0,0 +1,149 @@ +package org.mobilenativefoundation.store.store5 + +import co.touchlab.kermit.Logger +import kotlinx.atomicfu.atomic +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store.store5.impl.extensions.get +import org.mobilenativefoundation.store.store5.util.InMemoryPersister +import org.mobilenativefoundation.store.store5.util.asSourceOfTruth +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration + +class LocalOnlyTests { + private val testScope = TestScope() + + @Test + fun givenEmptyMemoryCacheThenCacheOnlyRequestReturnsNoNewData() = testScope.runTest { + val store = StoreBuilder + .from(Fetcher.of { _: Int -> throw RuntimeException("Fetcher shouldn't be hit") }) + .cachePolicy( + MemoryPolicy + .builder() + .build() + ) + .build() + val response = store.stream(StoreReadRequest.localOnly(0)).first() + assertEquals(StoreReadResponse.NoNewData(StoreReadResponseOrigin.Cache), response) + } + + @Test + fun givenPrimedMemoryCacheThenCacheOnlyRequestReturnsData() = testScope.runTest { + val fetcherHitCounter = atomic(0) + val store = StoreBuilder + .from(Fetcher.of { _: Int -> + fetcherHitCounter += 1 + "result" + }) + .cachePolicy( + MemoryPolicy + .builder() + .build() + ) + .build() + val a = store.get(0) + assertEquals("result", a) + assertEquals(1, fetcherHitCounter.value) + val response = store.stream(StoreReadRequest.localOnly(0)).first() + assertEquals("result", response.requireData()) + assertEquals(1, fetcherHitCounter.value) + } + + @Test + fun givenInvalidMemoryCacheThenCacheOnlyRequestReturnsNoNewData() = testScope.runTest { + val fetcherHitCounter = atomic(0) + val store = StoreBuilder + .from(Fetcher.of { _: Int -> + fetcherHitCounter += 1 + "result" + }) + .cachePolicy( + MemoryPolicy + .builder() + .setExpireAfterWrite(Duration.ZERO) + .build() + ) + .build() + val a = store.get(0) + assertEquals("result", a) + assertEquals(1, fetcherHitCounter.value) + val response = store.stream(StoreReadRequest.localOnly(0)).first() + assertEquals(StoreReadResponse.NoNewData(StoreReadResponseOrigin.Cache), response) + assertEquals(1, fetcherHitCounter.value) + } + + @Test + fun givenEmptyDiskCacheThenCacheOnlyRequestReturnsNoNewData() = testScope.runTest { + val persister = InMemoryPersister() + val store = StoreBuilder + .from( + fetcher = Fetcher.of { _: Int -> throw RuntimeException("Fetcher shouldn't be hit") }, + sourceOfTruth = persister.asSourceOfTruth() + ) + .disableCache() + .build() + val response = store.stream(StoreReadRequest.localOnly(0)).first() + assertEquals(StoreReadResponse.NoNewData(StoreReadResponseOrigin.SourceOfTruth), response) + } + + @Test + fun givenPrimedDiskCacheThenCacheOnlyRequestReturnsData() = testScope.runTest { + val fetcherHitCounter = atomic(0) + val persister = InMemoryPersister() + val store = StoreBuilder + .from( + fetcher = Fetcher.of { _: Int -> + fetcherHitCounter += 1 + "result" + }, + sourceOfTruth = persister.asSourceOfTruth() + ) + .disableCache() + .build() + val a = store.get(0) + assertEquals("result", a) + assertEquals(1, fetcherHitCounter.value) + val response = store.stream(StoreReadRequest.localOnly(0)).first() + assertEquals("result", response.requireData()) + assertEquals(StoreReadResponseOrigin.SourceOfTruth, response.origin) + assertEquals(1, fetcherHitCounter.value) + } + + @Test + fun givenInvalidDiskCacheThenCacheOnlyRequestReturnsNoNewData() = testScope.runTest { + val fetcherHitCounter = atomic(0) + val persister = InMemoryPersister() + persister.write(0, "result") + val store = StoreBuilder + .from( + fetcher = Fetcher.of { _: Int -> + fetcherHitCounter += 1 + "result" + }, + sourceOfTruth = persister.asSourceOfTruth() + ) + .disableCache() + .validator(Validator.by { false }) + .build() + val a = store.get(0) + assertEquals("result", a) + assertEquals(1, fetcherHitCounter.value) + val response = store.stream(StoreReadRequest.localOnly(0)).first() + assertEquals(StoreReadResponse.NoNewData(StoreReadResponseOrigin.SourceOfTruth), response) + assertEquals(1, fetcherHitCounter.value) + } + + @Test + fun givenNoCacheThenCacheOnlyRequestReturnsNoNewData() = testScope.runTest { + val store = StoreBuilder + .from(Fetcher.of { _: Int -> throw RuntimeException("Fetcher shouldn't be hit") }) + .disableCache() + .build() + val response = store.stream(StoreReadRequest.localOnly(0)).first() + assertTrue(response is StoreReadResponse.NoNewData) + assertEquals(StoreReadResponseOrigin.Cache, response.origin) + } +} \ No newline at end of file