Skip to content

Commit

Permalink
Add cacheOnly option to StoreReadRequest (#586)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
wbrawner authored Dec 7, 2023
1 parent 3559a78 commit ab4d2e0
Show file tree
Hide file tree
Showing 3 changed files with 194 additions and 4 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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> ->
Expand Down Expand Up @@ -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")
}
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}

0 comments on commit ab4d2e0

Please sign in to comment.