Relevant Context

Check out Google’s Offline First guide for more information on conflict resolution strategies.

Conflict resolution is critical when your app makes local changes while offline or when there are discrepancies between the client and server data. The Bookkeeper helps by:

  • Versioning: Recording timestamps of failed syncs allows you to determine when changes occurred, which is essential for resolving conflicts.
  • Strategies: Depending on your application’s needs, you might implement different conflict resolution strategies. A common approach in mobile apps is “last write wins,” where the most recent change overwrites previous ones.

Purpose of the Bookkeeper

  • Tracking Failed Synchronizations: The Bookkeeper records instances when local updates fail to sync with the remote source.
  • Conflict Resolution: By keeping a record of failed syncs, the Bookkeeper enables the Store to identify and resolve conflicts between local and remote data upon the next synchronization attempt.
  • Data Consistency: Helps maintain consistency between the client’s local data and the server’s data by ensuring that unsynced changes are not forgotten and are eventually synchronized.

APIs

Bookkeeper

Bookkeeper has the following structure:

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
}
Key
Any
required

The type representing the key used to identify the data item.

getLastFailedSync(key: Key)
Long?

Returns the timestamp of the last failed sync attempt for the given key.

setLastFailedSync(key: Key, timestamp: Long = now())
Boolean

Records a failed sync attempt with the provided timestamp.

clear(key: Key)
Boolean

Clears the record of failed syncs for the given key.

clearAll()
Boolean

Clears all records of failed syncs.

Data Flow

1

Local Changes Made

The application makes a local change that needs to be synced with the remote source.

2

Sync Attempt

The Store attempts to update the remote source using the Updater.

3

Write Response Handling

  • On Success: If the sync succeeds, the Bookkeeper clears any records of failed syncs for that key.
  • On Failure: If the sync fails, the Bookkeeper records the failure along with a timestamp.
4

Conflict Resolution on Read

  • Before serving data, the Store checks with the Bookkeeper to see if there are any unsynced local changes.

  • If there are failed syncs, the Store attempts to resolve them before returning data.

Implementing a Bookkeeper

You can create a Bookkeeper using the Bookkeeper.by factory method:

val bookkeeper = Bookkeeper.by(
    getLastFailedSync = { key ->
        // Retrieve timestamp from storage
    },
    setLastFailedSync = { key, timestamp ->
        // Save timestamp to storage
        true
    },
    clear = { key ->
        // Remove entry from storage
        true
    },
    clearAll = {
        // Clear all entries from storage
        true
    }
)

Example

From the Trails app:

package org.mobilenativefoundation.trails.xplat.lib.market.post.impl.store

private fun createBookkeeper(): Bookkeeper<Int> =
  Bookkeeper.by(
      getLastFailedSync = { id ->
          trailsDatabase.postBookkeepingQueries
              .selectMostRecentFailedSync(id).executeAsOneOrNull()?.let { failedSync ->
                  timestampToEpochMilliseconds(timestamp = failedSync.timestamp)
              }
      },
      setLastFailedSync = { id, timestamp ->
          try {
              trailsDatabase.postBookkeepingQueries.insertFailedSync(
                  PostFailedSync(
                      post_id = id,
                      timestamp = epochMillisecondsToTimestamp(timestamp)
                  )
              )
              true
          } catch (e: SQLException) {
              // Handle the exception
              false
          }
      },
      clear = { id ->
          try {
              trailsDatabase.postBookkeepingQueries.clearByPostId(id)
              true
          } catch (e: SQLException) {
              // Handle the exception
              false
          }
      },
      clearAll = {
          try {
              trailsDatabase.postBookkeepingQueries.clearAll()
              true
          } catch (e: SQLException) {
              // Handle the exception
              false
          }
      }
  )

Best Practices

  • Persistent Storage: Use persistent storage (e.g., a local database) for the Bookkeeper to ensure that failed sync records are not lost between app sessions.
  • Error Handling: Ensure that exceptions are properly caught and handled when performing sync operations.