Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Write + Conflict Resolution #496

Merged
merged 17 commits into from
Dec 21, 2022
2 changes: 2 additions & 0 deletions .github/workflows/.ci_test_and_publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v2
with:
ref: ${{ github.event.pull_request.head.ref }}

- name: Set up our JDK environment
uses: actions/setup-java@v2
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v2
with:
ref: ${{ github.event.pull_request.head.ref }}
- name: Setup Gradle
uses: gradle/gradle-build-action@v2
- name: Run check with Gradle Wrapper
Expand Down
4 changes: 4 additions & 0 deletions buildSrc/src/main/kotlin/Deps.kt
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,8 @@ object Deps {
const val coroutinesTest = "org.jetbrains.kotlinx:kotlinx-coroutines-test:${Version.kotlinxCoroutines}"
const val junit = "junit:junit:${Version.junit}"
}

object Touchlab {
const val kermit = "co.touchlab:kermit:${Version.kermit}"
}
}
1 change: 1 addition & 0 deletions buildSrc/src/main/kotlin/Version.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ object Version {
const val junit = "4.13.2"
const val kotlinxCoroutines = "1.6.4"
const val kotlinxSerialization = "1.4.0"
const val kermit = "1.2.2"
const val testCore = "1.4.0"
const val kmmBridge = "0.3.2"
const val ktlint = "0.39.0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package org.mobilenativefoundation.store.cache5

import kotlin.time.Duration

class CacheBuilder<Key : Any, Value : Any> {
class CacheBuilder<Key : Any, CommonRepresentation : Any> {
internal var concurrencyLevel = 4
private set
internal val initialCapacity = 16
Expand All @@ -14,41 +14,41 @@ class CacheBuilder<Key : Any, Value : Any> {
private set
internal var expireAfterWrite: Duration = Duration.INFINITE
private set
internal var weigher: Weigher<Key, Value>? = null
internal var weigher: Weigher<Key, CommonRepresentation>? = null
private set
internal var ticker: Ticker? = null
private set

fun concurrencyLevel(producer: () -> Int): CacheBuilder<Key, Value> = apply {
fun concurrencyLevel(producer: () -> Int): CacheBuilder<Key, CommonRepresentation> = apply {
concurrencyLevel = producer.invoke()
}

fun maximumSize(maximumSize: Long): CacheBuilder<Key, Value> = apply {
fun maximumSize(maximumSize: Long): CacheBuilder<Key, CommonRepresentation> = apply {
if (maximumSize < 0) {
throw IllegalArgumentException("Maximum size must be non-negative.")
}
this.maximumSize = maximumSize
}

fun expireAfterAccess(duration: Duration): CacheBuilder<Key, Value> = apply {
fun expireAfterAccess(duration: Duration): CacheBuilder<Key, CommonRepresentation> = apply {
if (duration.isNegative()) {
throw IllegalArgumentException("Duration must be non-negative.")
}
expireAfterAccess = duration
}

fun expireAfterWrite(duration: Duration): CacheBuilder<Key, Value> = apply {
fun expireAfterWrite(duration: Duration): CacheBuilder<Key, CommonRepresentation> = apply {
if (duration.isNegative()) {
throw IllegalArgumentException("Duration must be non-negative.")
}
expireAfterWrite = duration
}

fun ticker(ticker: Ticker): CacheBuilder<Key, Value> = apply {
fun ticker(ticker: Ticker): CacheBuilder<Key, CommonRepresentation> = apply {
this.ticker = ticker
}

fun weigher(maximumWeight: Long, weigher: Weigher<Key, Value>): CacheBuilder<Key, Value> = apply {
fun weigher(maximumWeight: Long, weigher: Weigher<Key, CommonRepresentation>): CacheBuilder<Key, CommonRepresentation> = apply {
if (maximumWeight < 0) {
throw IllegalArgumentException("Maximum weight must be non-negative.")
}
Expand All @@ -57,7 +57,7 @@ class CacheBuilder<Key : Any, Value : Any> {
this.weigher = weigher
}

fun build(): Cache<Key, Value> {
fun build(): Cache<Key, CommonRepresentation> {
if (maximumSize != -1L && weigher != null) {
throw IllegalStateException("Maximum size cannot be combined with weigher.")
}
Expand Down
1 change: 1 addition & 0 deletions store/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ kotlin {
implementation(serializationCore)
implementation(dateTime)
}
implementation(Deps.Touchlab.kermit)
implementation(project(":multicast"))
implementation(project(":cache"))
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package org.mobilenativefoundation.store.store5

import org.mobilenativefoundation.store.store5.impl.RealBookkeeper
import org.mobilenativefoundation.store.store5.impl.extensions.now

/**
* Tracks when local changes fail to sync with network.
* @see [RealMarket] usage to persist write request failures and eagerly resolve conflicts before completing a read request.
*/
interface Bookkeeper<Key : Any> {
suspend fun getLastFailedSync(key: Key): Long?
suspend fun setLastFailedSync(key: Key, timestamp: Long = now()): Boolean
suspend fun clear(key: Key): Boolean
suspend fun clearAll(): Boolean

companion object {
fun <Key : Any> by(
getLastFailedSync: suspend (key: Key) -> Long?,
setLastFailedSync: suspend (key: Key, timestamp: Long) -> Boolean,
clear: suspend (key: Key) -> Boolean,
clearAll: suspend () -> Boolean
): Bookkeeper<Key> = RealBookkeeper(getLastFailedSync, setLastFailedSync, clear, clearAll)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package org.mobilenativefoundation.store.store5

interface Clear {
interface Key<Key : Any> {
/**
* Purge a particular entry from memory and disk cache.
* Persistent storage will only be cleared if a delete function was passed to
* [StoreBuilder.persister] or [StoreBuilder.nonFlowingPersister] when creating the [Store].
*/
suspend fun clear(key: Key)
}

interface All {
/**
* Purge all entries from memory and disk cache.
* Persistent storage will only be cleared if a clear function was passed to
* [StoreBuilder.persister] or [StoreBuilder.nonFlowingPersister] when creating the [Store].
*/
@ExperimentalStoreApi
suspend fun clear()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package org.mobilenativefoundation.store.store5

typealias Converter<Input, Output> = (input: Input) -> Output
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ interface Fetcher<Key : Any, NetworkRepresentation : Any> {
*/
operator fun invoke(key: Key): Flow<FetcherResult<NetworkRepresentation>>

interface Converter
matt-ramotar marked this conversation as resolved.
Show resolved Hide resolved

companion object {
/**
* "Creates" a [Fetcher] from a [flowFactory].
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package org.mobilenativefoundation.store.store5

import org.mobilenativefoundation.store.store5.impl.RealItemValidator

/**
* Enables custom validation of [Store] items.
* @see [ReadRequest]
*/
interface ItemValidator<CommonRepresentation : Any> {
/**
* Determines whether a [Store] item is valid.
digitalbuddha marked this conversation as resolved.
Show resolved Hide resolved
* If invalid, [Market] will get the latest network value using [NetworkFetcher].
digitalbuddha marked this conversation as resolved.
Show resolved Hide resolved
* [Market] will not validate network responses.
*/
suspend fun isValid(item: CommonRepresentation): Boolean

companion object {
fun <CommonRepresentation : Any> by(
validator: suspend (item: CommonRepresentation) -> Boolean
): ItemValidator<CommonRepresentation> = RealItemValidator(validator)
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package org.mobilenativefoundation.store.store5

import org.mobilenativefoundation.store.cache5.Cache
import kotlin.time.Duration
import kotlin.time.ExperimentalTime

fun interface Weigher<in K : Any, in V : Any> {
/**
Expand All @@ -18,17 +18,10 @@ internal object OneWeigher : Weigher<Any, Any> {
}

/**
* MemoryPolicy holds all required info to create MemoryCache
*
*
* This class is used, in order to define the appropriate parameters for the Memory [com.dropbox.android.external.cache3.Cache]
* to be built.
*
*
* MemoryPolicy is used by a [Store]
* and defines the in-memory cache behavior.
* Defines behavior of in-memory [Cache].
* Used by [Store].
* @see [Store]
*/
@ExperimentalTime
class MemoryPolicy<in Key : Any, in Value : Any> internal constructor(
val expireAfterWrite: Duration,
val expireAfterAccess: Duration,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package org.mobilenativefoundation.store.store5

interface MutableStore<Key : Any, CommonRepresentation : Any> :
Read.StreamWithConflictResolution<Key, CommonRepresentation>,
Write<Key, CommonRepresentation>,
Write.Stream<Key, CommonRepresentation>,
Clear.Key<Key>,
Clear
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package org.mobilenativefoundation.store.store5

data class OnFetcherCompletion<NetworkRepresentation : Any>(
val onSuccess: (FetcherResult.Data<NetworkRepresentation>) -> Unit,
val onFailure: (FetcherResult.Error) -> Unit
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package org.mobilenativefoundation.store.store5

data class OnUpdaterCompletion<NetworkWriteResponse : Any>(
val onSuccess: (UpdaterResult.Success) -> Unit,
val onFailure: (UpdaterResult.Error) -> Unit
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package org.mobilenativefoundation.store.store5

import kotlinx.coroutines.flow.Flow

interface Read {
digitalbuddha marked this conversation as resolved.
Show resolved Hide resolved
interface Stream<Key : Any, CommonRepresentation : Any> {
/**
* Return a flow for the given key
* @param request - see [StoreReadRequest] for configurations
*/
fun stream(request: StoreReadRequest<Key>): Flow<StoreReadResponse<CommonRepresentation>>
}

interface StreamWithConflictResolution<Key : Any, CommonRepresentation : Any> {
fun <NetworkWriteResponse : Any> stream(request: StoreReadRequest<Key>): Flow<StoreReadResponse<CommonRepresentation>>
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,34 +39,34 @@ import kotlin.jvm.JvmName
* a common flowing API.
*
* A source of truth is usually backed by local storage. It's purpose is to eliminate the need
* for waiting on network update before local modifications are available (via [Store.stream]).
* for waiting on network update before local modifications are available (via [Store.Stream.read]).
*
* For maximal flexibility, [writer]'s record type ([Input]] and [reader]'s record type
* ([Output]) are not identical. This allows us to read one type of objects from network and
* transform them to another type when placing them in local storage.
*
*/
interface SourceOfTruth<Key, Input, Output> {
interface SourceOfTruth<Key : Any, SourceOfTruthRepresentation : Any> {

/**
* Used by [Store] to read records from the source of truth.
*
* @param key The key to read for.
*/
fun reader(key: Key): Flow<Output?>
fun reader(key: Key): Flow<SourceOfTruthRepresentation?>

/**
* Used by [Store] to write records **coming in from the fetcher (network)** to the source of
* truth.
*
* **Note:** [Store] currently does not support updating the source of truth with local user
* updates (i.e writing record of type [Output]). However, any changes in the local database
* will still be visible via [Store.stream] APIs as long as you are using a local storage that
* will still be visible via [Store.Stream.read] APIs as long as you are using a local storage that
* supports observability (e.g. Room, SQLDelight, Realm).
*
* @param key The key to update for.
*/
suspend fun write(key: Key, value: Input)
suspend fun write(key: Key, value: SourceOfTruthRepresentation)

/**
* Used by [Store] to delete records in the source of truth for the given key.
Expand All @@ -90,12 +90,12 @@ interface SourceOfTruth<Key, Input, Output> {
* @param delete function for deleting records in the source of truth for the given key
* @param deleteAll function for deleting all records in the source of truth
*/
fun <Key : Any, Input : Any, Output : Any> of(
nonFlowReader: suspend (Key) -> Output?,
writer: suspend (Key, Input) -> Unit,
fun <Key : Any, SourceOfTruthRepresentation : Any> of(
nonFlowReader: suspend (Key) -> SourceOfTruthRepresentation?,
writer: suspend (Key, SourceOfTruthRepresentation) -> Unit,
delete: (suspend (Key) -> Unit)? = null,
deleteAll: (suspend () -> Unit)? = null
): SourceOfTruth<Key, Input, Output> = PersistentNonFlowingSourceOfTruth(
): SourceOfTruth<Key, SourceOfTruthRepresentation> = PersistentNonFlowingSourceOfTruth(
realReader = nonFlowReader,
realWriter = writer,
realDelete = delete,
Expand All @@ -112,12 +112,12 @@ interface SourceOfTruth<Key, Input, Output> {
* @param deleteAll function for deleting all records in the source of truth
*/
@JvmName("ofFlow")
fun <Key : Any, Input : Any, Output : Any> of(
reader: (Key) -> Flow<Output?>,
writer: suspend (Key, Input) -> Unit,
fun <Key : Any, SourceOfTruthRepresentation : Any> of(
reader: (Key) -> Flow<SourceOfTruthRepresentation?>,
writer: suspend (Key, SourceOfTruthRepresentation) -> Unit,
delete: (suspend (Key) -> Unit)? = null,
deleteAll: (suspend () -> Unit)? = null
): SourceOfTruth<Key, Input, Output> = PersistentSourceOfTruth(
): SourceOfTruth<Key, SourceOfTruthRepresentation> = PersistentSourceOfTruth(
realReader = reader,
realWriter = writer,
realDelete = delete,
Expand All @@ -128,7 +128,7 @@ interface SourceOfTruth<Key, Input, Output> {
/**
* The exception provided when a write operation fails in SourceOfTruth.
*
* see [StoreResponse.Error.Exception]
* see [StoreReadResponse.Error.Exception]
*/
class WriteException(
/**
Expand Down Expand Up @@ -169,7 +169,7 @@ interface SourceOfTruth<Key, Input, Output> {
/**
* Exception created when a [reader] throws an exception.
*
* see [StoreResponse.Error.Exception]
* see [StoreReadResponse.Error.Exception]
*/
class ReadException(
/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
package org.mobilenativefoundation.store.store5

import kotlinx.coroutines.flow.Flow

/**
* A Store is responsible for managing a particular data request.
*
Expand Down Expand Up @@ -33,26 +31,7 @@ import kotlinx.coroutines.flow.Flow
* }
*
*/
interface Store<Key : Any, Output : Any> {

/**
* Return a flow for the given key
* @param request - see [StoreRequest] for configurations
*/
fun stream(request: StoreRequest<Key>): Flow<StoreResponse<Output>>

/**
* Purge a particular entry from memory and disk cache.
* Persistent storage will only be cleared if a delete function was passed to
* [StoreBuilder.persister] or [StoreBuilder.nonFlowingPersister] when creating the [Store].
*/
suspend fun clear(key: Key)

/**
* Purge all entries from memory and disk cache.
* Persistent storage will only be cleared if a deleteAll function was passed to
* [StoreBuilder.persister] or [StoreBuilder.nonFlowingPersister] when creating the [Store].
*/
@ExperimentalStoreApi
suspend fun clearAll()
}
interface Store<Key : Any, CommonRepresentation : Any> :
Read.Stream<Key, CommonRepresentation>,
Clear.Key<Key>,
Clear.All
Loading