Add cacheOnly option to StoreReadRequest (#586)

* Add cacheOnly option to StoreReadRequest

Signed-off-by: William Brawner <me@wbrawner.com>

* Fix doc on StoreReadRequest.cacheOnly

Signed-off-by: William Brawner <me@wbrawner.com>

* Hit disk caches for cacheOnly requests

Signed-off-by: William Brawner <me@wbrawner.com>

* Rename cacheOnly to localOnly

Signed-off-by: William Brawner <william.p.brawner@aexp.com>

* Send NoNewData and log warning for localOnly requests with no local data sources configured

Signed-off-by: William Brawner <william.p.brawner@aexp.com>

---------

Signed-off-by: William Brawner <me@wbrawner.com>
Signed-off-by: William Brawner <william.p.brawner@aexp.com>
This commit is contained in:
William Brawner 2023-12-07 15:32:09 -07:00 committed by GitHub
parent 3559a786b6
commit ab4d2e088b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 194 additions and 4 deletions

View file

@ -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<out Key> 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<out Key> 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 <Key> cached(key: Key, refresh: Boolean) = StoreReadRequest(
@ -66,6 +68,16 @@ data class StoreReadRequest<out Key> private constructor(
refresh = refresh
)
/**
* Create a [StoreReadRequest] which will return data from memory/disk caches if present,
* otherwise will return [StoreReadResponse.NoNewData]
*/
fun <Key> 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)

View file

@ -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<Key : Any, Network : Any, Output : Any, Local : Any>(
// 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<StoreReadResponse<Output>> = 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<Key : Any, Network : Any, Output : Any, Local : Any>(
networkLock = null,
piggybackOnly = piggybackOnly
) as Flow<StoreReadResponse<Output>> // when no source of truth Input == Output
} else {
} else if (request.fetch) {
diskNetworkCombined(request, sourceOfTruth)
} else {
val diskLock = CompletableDeferred<Unit>()
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<Output> ->
@ -312,4 +334,11 @@ internal class RealStore<Key : Any, Network : Any, Output : Any, Local : Any>(
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")
}
}
}

View file

@ -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<Any, Any>()
.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<Any, Any>()
.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<Any, Any>()
.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<Int, String>()
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<Int, String>()
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<Int, String>()
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)
}
}