Skip to content

Handling Data Conflicts

Couchbase Lite Database Sync — handling conflict between data changes

Causes of Conflicts

Document conflicts can occur if multiple changes are made to the same version of a document by multiple peers in a distributed system. For Couchbase Mobile, this can be a Couchbase Lite or Sync Gateway database instance.

Such conflicts can occur after either of the following events:

  • A replication saves a document change — in which case the change with the most-revisions wins (unless one change is a delete). See Conflicts when Replicating
  • An application saves a document change directly to a database instance — in which case, last write wins, unless one change is a delete — see Conflicts when Updating

Note

Deletes always win. So, in either of the above cases, if one of the changes was a delete then that change wins.

The following sections discuss each scenario in more detail.

Conflicts when Replicating

There’s no practical way to prevent a conflict when incompatible changes to a document are be made in multiple instances of an app. The conflict is realized only when replication propagates the incompatible changes to each other.

Example 1. A typical replication conflict scenario

  1. Molly uses her device to create DocumentA.
  2. Replication syncs DocumentA to Naomi’s device.
  3. Molly uses her device to apply ChangeX to DocumentA.
  4. Naomi uses her device to make a different change, ChangeY, to DocumentA.
  5. Replication syncs ChangeY to Molly’s device.

    This device already has ChangeX putting the local document in conflict.
  6. Replication syncs ChangeX to Naomi’s device.

    This device already has ChangeY and now Naomi’s local document is in conflict.

Automatic Conflict Resolution

Note

The rules only apply to conflicts caused by replication. Conflict resolution takes place exclusively during pull replication, while push replication remains unaffected.

Couchbase Lite uses the following rules to handle conflicts such as those described in A typical replication conflict scenario:

  • If one of the changes is a deletion:

    A deleted document (that is, a tombstone) always wins over a document update.
  • If both changes are document changes:

    The change with the most revisions will win.

    Since each change creates a revision with an ID prefixed by an incremented version number, the winner is the change with the highest version number.

The result is saved internally by the Couchbase Lite replicator. Those rules describe the internal behavior of the replicator. For additional control over the handling of conflicts, including when a replication is in progress, see Custom Conflict Resolution.

Custom Conflict Resolution

Starting in Couchbase Lite 2.6, application developers who want more control over how document conflicts are handled can use custom logic to select the winner between conflicting revisions of a document.

If a custom conflict resolver is not provided, the system will automatically resolve conflicts as discussed in Automatic Conflict Resolution, and as a consequence there will be no conflicting revisions in the database.

Caution

While this is true of any user defined functions, app developers must be strongly cautioned against writing sub-optimal custom conflict handlers that are time consuming and could slow down the client’s save operations.

To implement custom conflict resolution during replication, you must implement the following steps:

  1. Conflict Resolver
  2. Configure the Replicator

Conflict Resolver

Apps have the following strategies for resolving conflicts:

  • Local Wins: The current revision in the database wins.
  • Remote Wins: The revision pulled from the remote endpoint through replication wins.
  • Merge: Merge the content bodies of the conflicting revisions.

Example 2. Using conflict resolvers

val localWinsResolver: ConflictResolver = { conflict ->
    conflict.localDocument
}
config.conflictResolver = localWinsResolver
val remoteWinsResolver: ConflictResolver = { conflict ->
    conflict.remoteDocument
}
config.conflictResolver = remoteWinsResolver
val mergeConflictResolver: ConflictResolver = { conflict ->
    val localDoc = conflict.localDocument?.toMap()?.toMutableMap()
    val remoteDoc = conflict.remoteDocument?.toMap()?.toMutableMap()

    val merge: MutableMap<String, Any?>?
    if (localDoc == null) {
        merge = remoteDoc
    } else {
        merge = localDoc
        if (remoteDoc != null) {
            merge.putAll(remoteDoc)
        }
    }

    if (merge == null) {
        MutableDocument(conflict.documentId)
    } else {
        MutableDocument(conflict.documentId, merge)
    }
}
config.conflictResolver = mergeConflictResolver

When a null document is returned by the resolver, the conflict will be resolved as a document deletion.

Important Guidelines and Best Practices

Points of Note:

  • If you have multiple replicators, it is recommended that instead of distinct resolvers, you should use a unified conflict resolver across all replicators. Failure to do so could potentially lead to data loss under exception cases or if the app is terminated (by the user or an app crash) while there are pending conflicts.
  • If the document ID of the document returned by the resolver does not correspond to the document that is in conflict then the replicator will log a warning message.

Important

Developers are encouraged to review the warnings and fix the resolver to return a valid document ID.

  • If a document from a different database is returned, the replicator will treat it as an error. A document replication event will be posted with an error and an error message will be logged.

Important

Apps are encouraged to observe such errors and take appropriate measures to fix the resolver function.

  • When the replicator is stopped, the system will attempt to resolve outstanding and pending conflicts before stopping. Hence, apps should expect to see some delay when attempting to stop the replicator depending on the number of outstanding documents in the replication queue and the complexity of the resolver function.
  • If there is an exception thrown in the ConflictResolver function, the exception will be caught and handled:
    • The conflict to resolve will be skipped. The pending conflicted documents will be resolved when the replicator is restarted.
    • The exception will be reported in the warning logs.
    • The exception will be reported in the document replication event.

Important

While the system will handle exceptions in the manner specified above, it is strongly encouraged for the resolver function to catch exceptions and handle them in a way appropriate to their needs.

Configure the Replicator

The implemented custom conflict resolver can be registered on the ReplicatorConfiguration object. The default value of the conflictResolver is null. When the value is null, the default conflict resolution will be applied.

Example 3. A Conflict Resolver

val collectionConfig = CollectionConfigurationFactory.newConfig(conflictResolver = localWinsResolver)
val repl = Replicator(
    ReplicatorConfigurationFactory.newConfig(
        target = URLEndpoint("ws://localhost:4984/mydatabase"),
        collections = mapOf(srcCollections to collectionConfig)
    )
)

// Start the replicator
// (be sure to hold a reference somewhere that will prevent it from being GCed)
repl.start()
this.replicator = repl

Conflicts when Updating

When updating a document, you need to consider the possibility of update conflicts. Update conflicts can occur when you try to update a document that’s been updated since you read it.

Example 4. How Updating May Cause Conflicts

Here’s a typical sequence of events that would create an update conflict:

  1. Your code reads the document’s current properties, and constructs a modified copy to save.
  2. Another thread (perhaps the replicator) updates the document, creating a new revision with different properties.
  3. Your code updates the document with its modified properties, for example using Collection.save(MutableDocument).

Automatic Conflict Resolution

In Couchbase Lite, by default, the conflict is automatically resolved and only one document update is stored in the database. The Last-Write-Win (LWW) algorithm is used to pick the winning update. So in effect, the changes from step 2 would be overwritten and lost.

If the probability of update conflicts is high in your app, and you wish to avoid the possibility of overwritten data, the save() and delete() APIs provide additional method signatures with concurrency control:

Save operations

Collection.save(MutableDocument, ConcurrencyControl) — attempts to save the document with a concurrency control.

The ConcurrencyControl parameter has two possible values:

  • LAST_WRITE_WINS (default): The last operation wins if there is a conflict.
  • FAIL_ON_CONFLICT: The operation will fail if there is a conflict.

    In this case, the app can detect the error that is being thrown, and handle it by re-reading the document, making the necessary conflict resolution, then trying again.

Delete operations

As with save operations, delete operations also have two method signatures, which specify how to handle a possible conflict:

Custom Conflict Handlers

Developers can hook a conflict handler when saving a document, so they can easily handle the conflict in a single save method call.

To implement custom conflict resolution when saving a document, apps must call the save method with a conflict handler block (Collection.save(MutableDocument, ConflictHandler)).

The following code snippet shows an example of merging properties from the existing document (curDoc) into the one being saved (newDoc). In the event of conflicting keys, it will pick the key value from newDoc.

Example 5. Merging document properties

val mutableDocument = collection.getDocument("xyz")?.toMutable() ?: return
mutableDocument.setString("name", "apples")
collection.save(mutableDocument) { newDoc, curDoc ->
    if (curDoc == null) {
        return@save false
    }
    val dataMap: MutableMap<String, Any?> = curDoc.toMap().toMutableMap()
    dataMap.putAll(newDoc.toMap())
    newDoc.setData(dataMap)
    true
}