Handling Data Conflicts

      +

      Description — 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:

      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 cause of replication conflicts:
      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

      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.

      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.

      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
      • Local Wins

      • Remote Wins

      • Merge

      class LocalWinConflictResolver: ConflictResolverProtocol {
          func resolve(conflict: Conflict) -> Document? {
              return conflict.localDocument
          }
      }
      class RemoteWinConflictResolver: ConflictResolverProtocol {
          func resolve(conflict: Conflict) -> Document? {
              return conflict.remoteDocument
          }
      }
      class MergeConflictResolver: ConflictResolverProtocol {
          func resolve(conflict: Conflict) -> Document? {
              let localDict = conflict.localDocument!.toDictionary()
              let remoteDict = conflict.remoteDocument!.toDictionary()
              let result = localDict.merging(remoteDict) { (current, new) -> Any in
                  return current // return current value in case of duplicate keys
              }
              return MutableDocument(id: conflict.documentID, data: result)
          }
      }

      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.

        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.

        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 resolve() method, 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.

          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 replicator configuration 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
      let url = URL(string: "wss://localhost:4984/mydatabase")!
      let target = URLEndpoint(url: url)
      
      var config = ReplicatorConfiguration(target: target)
      var colConfig = CollectionConfiguration()
      colConfig.conflictResolver = LocalWinConflictResolver()
      config.addCollection(collection, config: colConfig)
      
      self.replicator = Replicator(config: config)
      self.replicator.start()

      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 Database.saveDocument(_:).

      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:

      Example 5. Currency Control Signatures
      Save operations

      Database.saveDocument(_:concurencyControl:) — attempts to save the document with a concurrency control.

      The concurrency control parameter has two possible values:

      • lastWriteWins (default): The last operation wins if there is a conflict.

      • failOnConflict: 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 operation also have two method signatures, which specify how to handle a possible conflict:

      The concurrency control parameter has two possible values:

      • lastWriteWins (default): The last operation wins if there is a conflict.

      • failOnConflict: 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.

      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 ( Database.saveDocument(_:conflictHandler:)).

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

      Example 6. Merging document properties
      guard let document = try collection.document(id: "xyz") else { return }
      let mutableDocument = document.toMutable()
      mutableDocument.setString("apples", forKey: "name")
      let success = try collection.save(document:mutableDocument, conflictHandler: { (new, current) -> Bool in
          let currentDict = current!.toDictionary()
          let newDict = new.toDictionary()
          let result = newDict.merging(currentDict, uniquingKeysWith: { (first, _) in first })
          new.setData(result)
          return true
      })